# zeroCPR Complement Guardrails Implementation Plan
> For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.
Goal: Add generation-time guardrails to the zeroCPR complement fallback so it stops proposing hub-collapsed, cross-section, supplier-conflicting, and cross-brand-incompatible complements, writing a non-destructive signal_type='zerocpr_v2'.
Architecture: New pure-predicate module core/complement_gates.py (section / material / supplier gates + lexicons) and a composition module core/complement_guardrails.py (build_guarded_blocks) that runs the gates then a hub-aware SKU fill. Reuse existing core/complement_families.py machinery (brand_compatible, is_durable_anchor, per_family_cap, assemble_anchor_blocks); extend it with _DURABLE_RX (staplers/calculators) and a new cross-anchor hub_adjusted_rank. A new runner scripts/refresh_complements_guardrails.py orchestrates I/O, re-calls qwen on pruned families with a section-filtered vocabulary, and persists zerocpr_v2.
Tech Stack: Python 3.12 (venv), pytest, psycopg2 (Postgres :5433), Ollama/qwen on gpu-wsl (backend='ollama'). Pure functions carry no I/O.
Global Constraints
- Pure functions in
core/— NO DB or network I/O; all I/O lives inscripts/. - Never mutate curated or
zerocprrows: v2 writessignal_type='zerocpr_v2',source_file='zerocpr-v2-2026-07-03', DELETE-by-(source_country, source_file, anchor)+INSERT only. - COUNTRY='FR' for this run. All product refs are 18-char zero-padded
product_reference. - Gates treat missing data (unknown brand/section/material) as pass — never block on absence (matches
brand_compatible). - LLM re-calls use qwen only (
backend='ollama', host 'gpu-wsl', concurrency ≤5) — ZERO Claude quota. - Run tests with
venv\Scripts\python.exe -m pytest. Commit after each task. - Hub-IDF penalty defaults (
penalty_k=1.0,cap=200,min_sales=1) are the working defaults pending gridiron's cross-check (mesh #3908); they are parameters, tune later without code change.
---
Task 1: Section-coherence gate
Files:
- Create:
core/complement_gates.py - Test:
tests/test_complement_gates.py
Interfaces:
- Produces:
SECTION_ADJACENCY: dict[str, set[str]];section_coherent(anchor_section: str, family_section: str) -> bool(True if same section, or the pair is adjacent, or either is empty/unknown).
- [ ] Step 1: Write the failing test
`python
# tests/test_complement_gates.py
from core.complement_gates import section_coherent
def test_section_coherent_same_adjacent_and_unknown():
assert section_coherent("003", "003") is True # same section
assert section_coherent("003", "002") is True # EPI <-> Hygiene (adjacent)
assert section_coherent("002", "003") is True # symmetric
assert section_coherent("004", "006") is True # printer cluster
assert section_coherent("005", "004") is True # printer cluster mutual
assert section_coherent("003", "001") is False # EPI -> Restauration: reject
assert section_coherent("003", "010") is False # EPI -> Ecriture: reject
# unknown/empty never blocks (missing data -> pass)
assert section_coherent("", "010") is True
assert section_coherent("003", "") is True
assert section_coherent(None, "010") is True
`
- [ ] Step 2: Run test to verify it fails
Run: venv\Scripts\python.exe -m pytest tests/test_complement_gates.py::test_section_coherent_same_adjacent_and_unknown -v
Expected: FAIL (ModuleNotFoundError: core.complement_gates)
- [ ] Step 3: Write minimal implementation
`python
# core/complement_gates.py
"""Guardrails compléments zeroCPR v2 — prédicats purs (aucun I/O).
Complète core.complement_families : cohérence de section, compatibilité matière (chaussures cuir vs textile), protection fournisseur (pas de marque propre Lyréco sur un durable concurrent), et filtrage de vocabulaire par section pour le re-call LLM. Décision : docs/superpowers/specs/2026-07-03-zerocpr-guardrails-design.md """ from __future__ import annotations
import re
# Adjacence de sections autorisée (symétrique). Codes = SHARED taxonomy FR. # Seed initial (affinable) : EPI<->Hygiène, cluster impression (info/conso/machines), # Restauration<->Hygiène, Emballage<->Fournitures, Cahiers<->Écriture<->Fournitures. _ADJ_SEED = [ ("003", "002"), ("001", "002"), ("004", "005"), ("004", "006"), ("005", "006"), ("011", "016"), ("009", "010"), ("009", "011"), ("010", "011"), ] SECTION_ADJACENCY: dict[str, set[str]] = {} for _a, _b in _ADJ_SEED: SECTION_ADJACENCY.setdefault(_a, set()).add(_b) SECTION_ADJACENCY.setdefault(_b, set()).add(_a)
def section_coherent(anchor_section, family_section) -> bool:
"""True si la famille complément est dans la même section que l'ancre, ou une
section adjacente autorisée. Une section inconnue/vide ne bloque jamais (on ne
sait pas -> on garde), comme brand_compatible. Pur."""
a = (anchor_section or "").strip()
f = (family_section or "").strip()
if not a or not f:
return True
if a == f:
return True
return f in SECTION_ADJACENCY.get(a, set())
`
- [ ] Step 4: Run test to verify it passes
Run: venv\Scripts\python.exe -m pytest tests/test_complement_gates.py::test_section_coherent_same_adjacent_and_unknown -v
Expected: PASS
- [ ] Step 5: Commit
`bash
git add core/complement_gates.py tests/test_complement_gates.py
git commit -m "feat(gates): section-coherence gate + adjacency allow-list"
`
---
Task 2: Material tagger + material compatibility
Files:
- Modify:
core/complement_gates.py - Test:
tests/test_complement_gates.py
Interfaces:
- Consumes: nothing from Task 1.
- Produces:
tag_materials(text: str) -> set[str];material_compatible(anchor_text: str, sku_text: str) -> bool(only meaningful for footwear-care/gloves; caller decides when to apply). Blocks only on a genuine leather-vs-textile clash; unknown → pass.
- [ ] Step 1: Write the failing test
`python
# tests/test_complement_gates.py (append)
from core.complement_gates import tag_materials, material_compatible
def test_tag_materials_extracts_known_materials(): assert tag_materials("SPRAY IMPERMEABILISANT CUIR ET DAIM") == {"cuir"} assert tag_materials("LOT CHAUSSETTES COTON TEXTILE") == {"textile"} assert tag_materials("GANTS NITRILE JETABLES BLEU") == {"nitrile"} assert tag_materials("BOITE CARTON") == set() # nothing relevant assert tag_materials("") == set()
def test_material_compatible_blocks_leather_vs_textile_only():
# leather shoe anchor + textile-only care product -> clash -> block
assert material_compatible("CHAUSSURE SECURITE CUIR S3", "DEODORANT TEXTILE COTON") is False
# leather anchor + leather care -> ok
assert material_compatible("CHAUSSURE SECURITE CUIR S3", "SPRAY ENTRETIEN CUIR") is True
# anchor has material, complement has none -> unknown -> pass
assert material_compatible("CHAUSSURE SECURITE CUIR S3", "SPRAY DESODORISANT") is True
# neither has material -> pass
assert material_compatible("GANT", "LINGETTE") is True
`
- [ ] Step 2: Run test to verify it fails
Run: venv\Scripts\python.exe -m pytest tests/test_complement_gates.py -k material -v
Expected: FAIL (ImportError: cannot import name 'tag_materials')
- [ ] Step 3: Write minimal implementation
`python
# core/complement_gates.py (append)
# Lexique matière v1 (chaussures/gants). tag -> mots-clés (FR/EN). On mappe les # synonymes vers un tag canonique exclusif : cuir vs textile s'excluent. _MATERIAL_LEXICON: dict[str, list[str]] = { "cuir": ["cuir", "leather", "daim", "nubuck"], "textile": ["textile", "coton", "cotton", "tissu", "toile", "mesh", "synthetique"], "nitrile": ["nitrile"], "latex": ["latex"], "vinyle": ["vinyle", "vinyl"], } # Groupe exclusif : au sein de {cuir, textile}, deux tags différents = incompatibilité # d'entretien (on n'imperméabilise pas du coton avec un soin cuir, et inversement). _MATERIAL_EXCLUSIVE = {"cuir", "textile"}
def tag_materials(text: str) -> set[str]: """Ensemble de tags matière canoniques trouvés dans le texte libre. Pur.""" t = (text or "").lower() tags = set() for tag, kws in _MATERIAL_LEXICON.items(): if any(re.search(r"\b" + re.escape(kw), t) for kw in kws): tags.add(tag) return tags
def material_compatible(anchor_text: str, sku_text: str) -> bool:
"""False seulement si l'ancre et le complément portent des matières EXCLUSIVES
différentes (cuir vs textile) sans recouvrement. Matière absente d'un côté =
inconnu -> on ne bloque pas. À n'appeler que pour les familles sensibles
(chaussures/gants). Pur."""
a = tag_materials(anchor_text) & _MATERIAL_EXCLUSIVE
s = tag_materials(sku_text) & _MATERIAL_EXCLUSIVE
if a and s and a.isdisjoint(s):
return False
return True
`
- [ ] Step 4: Run test to verify it passes
Run: venv\Scripts\python.exe -m pytest tests/test_complement_gates.py -k material -v
Expected: PASS
- [ ] Step 5: Commit
`bash
git add core/complement_gates.py tests/test_complement_gates.py
git commit -m "feat(gates): material tagger + leather-vs-textile compatibility"
`
---
Task 3: Durable-detector extension + supplier-protection gate
Files:
- Modify:
core/complement_families.py:59-67(the_DURABLE_RXregex) - Modify:
core/complement_gates.py - Test:
tests/test_complement_families.py,tests/test_complement_gates.py
Interfaces:
- Consumes:
is_durable_anchor(fromcomplement_families). - Produces:
is_own_brand(rec: dict) -> bool;resolve_fit_critical_families(taxonomy_rows: list[dict], patterns: list[str] = FIT_CRITICAL_LABEL_PATTERNS) -> set[str];FIT_CRITICAL_LABEL_PATTERNS: list[str];supplier_protected(anchor: dict, sku: dict, fit_critical_family_codes: set[str]) -> bool(True = allowed, False = block the own-brand-on-competitor-durable case).
- [ ] Step 1: Write the failing test
`python
# tests/test_complement_families.py (append)
from core.complement_families import is_durable_anchor as _ida
def test_durable_rx_covers_staplers_and_calculators():
assert _ida("RAPID CLASSIC 66 STAPLER") is True
assert _ida("AGRAFEUSE LEITZ 5502") is True
assert _ida("CASIO HR-8RCE CALCULATRICE IMPRIMANTE") is True
assert _ida("CALCULATOR CITIZEN SDC-888") is True
# a terminal consumable stays non-durable
assert _ida("PK5000 AGRAFES 24/6") is False
`
`python
# tests/test_complement_gates.py (append)
from core.complement_gates import (
is_own_brand, resolve_fit_critical_families, supplier_protected,
FIT_CRITICAL_LABEL_PATTERNS,
)
def test_resolve_fit_critical_families_matches_labels(): rows = [ {"family_code": "111", "family": "AGRAFES"}, {"family_code": "222", "family": "ENCRES ET TONERS"}, {"family_code": "333", "family": "STYLOS BILLE"}, {"family_code": "444", "family": "RUBANS ET ETIQUETTES"}, ] got = resolve_fit_critical_families(rows) assert got == {"111", "222", "444"} # staples, ink/toner, tape/labels
def test_supplier_protected_blocks_ownbrand_on_competitor_durable():
fit = {"111"} # agrafes = fit-critical
stapler = {"description": "RAPID CLASSIC 66 STAPLER", "brand": "RAPID",
"lyreco_brand": False}
lyreco_staples = {"reference": "s1", "description": "PK5000 LYRECO AGRAFES 24/6",
"brand": "LYRECO", "lyreco_brand": True, "family_code": "111"}
rapid_staples = {"reference": "s2", "description": "PK5000 RAPID AGRAFES 24/6",
"brand": "RAPID", "lyreco_brand": False, "family_code": "111"}
# own-brand staples on a competitor stapler in a fit-critical family -> BLOCK
assert supplier_protected(stapler, lyreco_staples, fit) is False
# supplier's own staples -> allowed
assert supplier_protected(stapler, rapid_staples, fit) is True
# non-fit-critical family -> never blocked
assert supplier_protected(stapler, {lyreco_staples, "family_code": "999"}, fit) is True
# anchor already own-brand (no supplier to protect) -> allowed
own_stapler = {stapler, "brand": "LYRECO", "lyreco_brand": True}
assert supplier_protected(own_stapler, lyreco_staples, fit) is True
# non-durable anchor -> gate does not apply
pen = {"description": "BIC CRISTAL PEN", "brand": "BIC", "lyreco_brand": False}
assert supplier_protected(pen, lyreco_staples, fit) is True
`
- [ ] Step 2: Run tests to verify they fail
Run: venv\Scripts\python.exe -m pytest tests/test_complement_families.py -k durable_rx tests/test_complement_gates.py -k "fit_critical or supplier" -v
Expected: FAIL (staplers not matched; ImportError on supplier_protected)
- [ ] Step 3: Write minimal implementation
In core/complement_families.py, extend _DURABLE_RX (add the three alternatives at the end of the pattern, before the closing quote on line 65-66):
`python
# core/complement_families.py (_DURABLE_RX, add to the alternation)
r"|ASPIRATEUR|VACUUM|\bMANCHE\b|BALAI"
r"|AGRAFEUSE|STAPLER|CALCULATRICE|CALCULATOR",
`
In core/complement_gates.py:
`python
# core/complement_gates.py (append)
FIT_CRITICAL_LABEL_PATTERNS = [ "agraf", "encre", "toner", "cartouche", "ruban", "etiquet", "rouleau", "dosette", "capsule", ]
_OWN_BRAND_RX = re.compile(r"lyreco", re.IGNORECASE)
def is_own_brand(rec: dict) -> bool: """True si la réf est une marque propre Lyréco (flag lyreco_brand vrai, ou marque contenant 'lyreco'). Pur.""" lb = rec.get("lyreco_brand") if lb is True: return True if isinstance(lb, str) and lb.strip() and lb.strip().lower() not in ("n", "no", "false", "0"): return True return bool(_OWN_BRAND_RX.search((rec.get("brand") or "")))
def resolve_fit_critical_families(taxonomy_rows, patterns=FIT_CRITICAL_LABEL_PATTERNS) -> set: """Codes famille dont le libellé matche un motif 'consommable captif' (agrafes, encres/toner, rubans/étiquettes, rouleaux, dosettes/capsules). Pur.""" pats = [p.lower() for p in patterns] out = set() for r in taxonomy_rows: label = (r.get("family") or "").lower() code = r.get("family_code") if code and any(p in label for p in pats): out.add(code) return out
def supplier_protected(anchor: dict, sku: dict, fit_critical_family_codes: set) -> bool:
"""True = autorisé ; False = à BLOQUER. Bloque uniquement le cas politique : une
ancre DURABLE de marque CONCURRENTE (pas own-brand) reçoit un complément MARQUE
PROPRE Lyréco dans une famille consommable captive (agrafes pour agrafeuse, encre
pour imprimante...). Protège la relation fournisseur (ex. Rapid). Distinct de
brand_compatible (compat physique) : bloque même si compatible. Pur."""
from core.complement_families import is_durable_anchor
if not is_durable_anchor(anchor.get("description") or ""):
return True
anchor_is_competitor = (anchor.get("brand") or "").strip() and not is_own_brand(anchor)
if not anchor_is_competitor:
return True
if sku.get("family_code") not in fit_critical_family_codes:
return True
return not is_own_brand(sku)
`
- [ ] Step 4: Run tests to verify they pass
Run: venv\Scripts\python.exe -m pytest tests/test_complement_families.py -k durable_rx tests/test_complement_gates.py -k "fit_critical or supplier" -v
Expected: PASS
- [ ] Step 5: Commit
`bash
git add core/complement_families.py core/complement_gates.py tests/test_complement_families.py tests/test_complement_gates.py
git commit -m "feat(gates): supplier-protection gate + durable detector covers staplers/calculators"
`
---
Task 4: Cross-anchor hub-IDF adjusted ranking
Files:
- Modify:
core/complement_families.py(addhub_adjusted_rankafterrank_family_skus) - Test:
tests/test_complement_families.py
Interfaces:
- Consumes:
_is_active,is_in_perimeter(module-internal). - Produces:
hub_adjusted_rank(candidates: list[dict], top_n: int, attach_counts: dict[str, int], penalty_k: float = 1.0, cap: int | None = None, min_sales: int = 1, active_only: bool = True, perimeter_only: bool = True) -> list[dict]. Score =log1p(sales) - penalty_k*log1p(attach_counts.get(ref,0)); SKUs at/overcapattachments are excluded; a per-SKUmin_salesfloor is applied; ties broken by reference asc;statuslabel added likerank_family_skus.
- [ ] Step 1: Write the failing test
`python
# tests/test_complement_families.py (append)
from core.complement_families import hub_adjusted_rank
def test_hub_adjusted_rank_demotes_hubs_and_enforces_cap():
cands = [
_sku("hub", 1000), # top seller but already glued everywhere
_sku("mid", 300), # decent, rarely used
_sku("low", 5), # tail
]
attach = {"hub": 5000, "mid": 2, "low": 0}
# strong penalty pushes the fresh 'mid' above the over-attached 'hub'
out = hub_adjusted_rank(cands, top_n=2, attach_counts=attach, penalty_k=1.0)
assert [s["reference"] for s in out] == ["mid", "hub"]
# hard cap excludes a SKU already at/over the cap entirely
capped = hub_adjusted_rank(cands, top_n=3, attach_counts=attach, penalty_k=1.0, cap=1000)
assert "hub" not in [s["reference"] for s in capped]
# min_sales floor drops the tail SKU
floored = hub_adjusted_rank(cands, top_n=5, attach_counts={}, min_sales=10)
assert [s["reference"] for s in floored] == ["hub", "mid"]
# no penalty, no cap == sales order (parity with rank_family_skus intent)
plain = hub_adjusted_rank(cands, top_n=3, attach_counts={}, penalty_k=0.0, min_sales=0)
assert [s["reference"] for s in plain] == ["hub", "mid", "low"]
`
- [ ] Step 2: Run test to verify it fails
Run: venv\Scripts\python.exe -m pytest tests/test_complement_families.py -k hub_adjusted -v
Expected: FAIL (ImportError: cannot import name 'hub_adjusted_rank')
- [ ] Step 3: Write minimal implementation
`python
# core/complement_families.py (append after rank_family_skus)
import math
def hub_adjusted_rank(candidates, top_n: int, attach_counts: dict, penalty_k: float = 1.0, cap: int | None = None, min_sales: int = 1, active_only: bool = True, perimeter_only: bool = True) -> list: """Comme rank_family_skus mais avec DÉMOTION anti-hub inter-ancres.
Le problème hub est trans-ancre (un même SKU collé à des milliers d'ancres) —
per_family_cap (intra-ancre) ne le traite pas. Ici :
attach_counts[ref]= nb d'ancres déjà servies par ce SKU dans le run courant ;- un SKU à
attach_counts >= capest exclu (garde-fou dur) ; min_sales= plancher (on ne remonte pas la longue traîne sans ventes) ;- score = log1p(ventes) − penalty_k·log1p(attach) : pénalité continue façon IDF.
reference asc (déterministe). Pur (attach_counts non muté)."""
pool = [s for s in candidates
if (not active_only or _is_active(s))
and (not perimeter_only or is_in_perimeter(s))
and (s.get("sales") or 0) >= min_sales
and (cap is None or attach_counts.get(s["reference"], 0) < cap)]
def score(s): sales = s.get("sales") or 0 att = attach_counts.get(s["reference"], 0) return math.log1p(sales) - penalty_k * math.log1p(att)
pool.sort(key=lambda s: (-score(s), s["reference"]))
return [{s, "status": "Active" if _is_active(s) else "Inactive"} for s in pool[:top_n]]
`
- [ ] Step 4: Run test to verify it passes
Run: venv\Scripts\python.exe -m pytest tests/test_complement_families.py -k hub_adjusted -v
Expected: PASS
- [ ] Step 5: Commit
`bash
git add core/complement_families.py tests/test_complement_families.py
git commit -m "feat(families): cross-anchor hub-IDF adjusted ranking (demotion + cap + floor)"
`
---
Task 5: Section-filtered vocabulary for the qwen re-call
Files:
- Modify:
core/complement_gates.py - Test:
tests/test_complement_gates.py
Interfaces:
- Consumes:
section_coherent(Task 1). - Produces:
section_filtered_vocab(anchor_section: str, families: list[dict]) -> list[tuple[str, str]]. Input families are dicts{"family_code","family","section_code"}; returns(family_code, family)tuples for families whosesection_codeis coherent with the anchor's section — the exact shapebuild_fallback_promptconsumes.
- [ ] Step 1: Write the failing test
`python
# tests/test_complement_gates.py (append)
from core.complement_gates import section_filtered_vocab
def test_section_filtered_vocab_keeps_only_coherent_families():
fams = [
{"family_code": "a1", "family": "GANTS", "section_code": "003"}, # same
{"family_code": "a2", "family": "ESSUYAGE", "section_code": "002"}, # adjacent
{"family_code": "a3", "family": "CAFE", "section_code": "001"}, # far -> drop
{"family_code": "a4", "family": "STYLOS", "section_code": "010"}, # far -> drop
]
got = section_filtered_vocab("003", fams)
assert got == [("a1", "GANTS"), ("a2", "ESSUYAGE")]
# tuples, in input order, ready for build_fallback_prompt
assert all(isinstance(t, tuple) and len(t) == 2 for t in got)
`
- [ ] Step 2: Run test to verify it fails
Run: venv\Scripts\python.exe -m pytest tests/test_complement_gates.py -k section_filtered -v
Expected: FAIL (ImportError: cannot import name 'section_filtered_vocab')
- [ ] Step 3: Write minimal implementation
`python
# core/complement_gates.py (append)
def section_filtered_vocab(anchor_section: str, families: list) -> list:
"""Restreint le vocabulaire familles passé au LLM aux seules familles cohérentes
de section avec l'ancre (même section ou adjacente). Le LLM ne PEUT alors pas
choisir hors-section. Retourne des tuples (code, libellé) — la forme attendue par
build_fallback_prompt. Pur."""
return [(f["family_code"], f["family"]) for f in families
if section_coherent(anchor_section, f.get("section_code"))]
`
- [ ] Step 4: Run test to verify it passes
Run: venv\Scripts\python.exe -m pytest tests/test_complement_gates.py -k section_filtered -v
Expected: PASS
- [ ] Step 5: Commit
`bash
git add core/complement_gates.py tests/test_complement_gates.py
git commit -m "feat(gates): section-filtered vocabulary for constrained qwen re-call"
`
---
Task 6: build_guarded_blocks — compose gates + hub-aware fill
Files:
- Create:
core/complement_guardrails.py - Test:
tests/test_complement_guardrails.py
Interfaces:
- Consumes:
section_coherent,material_compatible,supplier_protected(gates);hub_adjusted_rank,brand_compatible,is_durable_anchor(families). - Produces:
build_guarded_blocks(anchor, families_meta, family_skus, cfg, attach_counts) -> tuple[list[dict], list[str]].anchor:{"reference","description","section_code","brand","lyreco_brand"}.families_meta:{family_code: {"section_code","material_sensitive": bool}}.family_skus:{family_code: [sku dict,...]}(each sku has reference/description/sales/family_code/brand/lyreco_brand/web_title/subcategory_code/not_salable/not_visible).cfg:GuardConfigdataclass (below).attach_counts: mutable{ref: int}updated in place as SKUs are chosen. Returns(blocks, pruned_family_codes)wherepruned_family_codesare families that yielded zero SKUs after gating (candidates for qwen re-call).
- [ ] Step 1: Write the failing test
`python
# tests/test_complement_guardrails.py
from core.complement_guardrails import build_guarded_blocks, GuardConfig
def _anchor(ref, desc, section, brand="", own=False): return {"reference": ref, "description": desc, "section_code": section, "brand": brand, "lyreco_brand": own}
def _sku(ref, desc, sales, fam, section, brand="", own=False, sub="x"): return {"reference": ref, "description": desc, "sales": sales, "family_code": fam, "section_code": section, "brand": brand, "lyreco_brand": own, "web_title": desc, "subcategory_code": sub, "not_salable": False, "not_visible": False}
def test_build_guarded_blocks_drops_cross_section_family_as_pruned(): anchor = _anchor("A1", "CHAUSSURE SECURITE CUIR S3", "003") fams = {"F_deo": {"section_code": "001", "material_sensitive": False}, # far -> pruned "F_glove": {"section_code": "003", "material_sensitive": False}} # same -> kept skus = {"F_deo": [_sku("d1", "DEODORANT CORPS", 500, "F_deo", "001")], "F_glove": [_sku("g1", "GANTS NITRILE", 50, "F_glove", "003")]} cfg = GuardConfig(fit_critical={}, per_family_cap=3, max_total=20, penalty_k=1.0, cap=None, min_sales=1) blocks, pruned = build_guarded_blocks(anchor, fams, skus, cfg, attach_counts={}) refs = [b["reference"] for b in blocks] assert refs == ["g1"] # deodorant family dropped assert pruned == ["F_deo"]
def test_build_guarded_blocks_material_and_supplier_and_attach_update():
anchor = _anchor("A2", "CHAUSSURE CUIR", "003")
fams = {"F_care": {"section_code": "003", "material_sensitive": True}}
skus = {"F_care": [
_sku("c_text", "SPRAY TEXTILE COTON", 900, "F_care", "003"), # material clash -> drop
_sku("c_leath", "SPRAY CUIR", 100, "F_care", "003"), # ok
]}
cfg = GuardConfig(fit_critical={}, per_family_cap=3, max_total=20,
penalty_k=1.0, cap=None, min_sales=1)
attach = {}
blocks, pruned = build_guarded_blocks(anchor, fams, skus, cfg, attach_counts=attach)
assert [b["reference"] for b in blocks] == ["c_leath"]
assert pruned == []
assert attach["c_leath"] == 1 # attachment counter incremented in place
`
- [ ] Step 2: Run test to verify it fails
Run: venv\Scripts\python.exe -m pytest tests/test_complement_guardrails.py -v
Expected: FAIL (ModuleNotFoundError: core.complement_guardrails)
- [ ] Step 3: Write minimal implementation
`python
# core/complement_guardrails.py
"""Composition des garde-fous zeroCPR v2 : applique les gates puis le tri anti-hub.
Pur (aucun I/O). Le runner scripts/refresh_complements_guardrails.py fournit les données et persiste. Décision : docs/superpowers/specs/2026-07-03-zerocpr-guardrails-design.md """ from __future__ import annotations
from dataclasses import dataclass
from core.complement_families import ( hub_adjusted_rank, brand_compatible, is_durable_anchor, ) from core.complement_gates import ( section_coherent, material_compatible, supplier_protected, )
@dataclass class GuardConfig: fit_critical: set # codes famille consommables captifs per_family_cap: int = 3 # SKU max par famille (intra-ancre) max_total: int = 10 # blocs max par ancre penalty_k: float = 1.0 # force pénalité hub cap: int | None = 200 # plafond attachements inter-ancres min_sales: int = 1 # plancher ventes
def build_guarded_blocks(anchor, families_meta, family_skus, cfg, attach_counts): """Retourne (blocks, pruned_family_codes). Pour chaque famille candidate : 1) section_coherent(ancre, famille) sinon la famille est prunée ; 2) filtre SKU : supplier_protected + brand_compatible (si ancre durable) + material_compatible (si famille sensible) ; 3) hub_adjusted_rank sur les SKU restants (démotion hub + cap + plancher) ; 4) per_family_cap + max_total + dédup ; incrémente attach_counts en place. Famille sans aucun SKU retenu -> pruned (candidate re-call qwen).""" a_desc = anchor.get("description") or "" a_section = anchor.get("section_code") a_brand = anchor.get("brand") durable = is_durable_anchor(a_desc) blocks, seen, pruned = [], set(), []
for fam, meta in families_meta.items():
if not section_coherent(a_section, meta.get("section_code")):
pruned.append(fam)
continue
cands = []
for s in family_skus.get(fam, []):
if not supplier_protected(anchor, s, cfg.fit_critical):
continue
if durable and not brand_compatible(a_brand, s):
continue
if meta.get("material_sensitive") and not material_compatible(
a_desc, (s.get("description") or "") + " " + (s.get("web_title") or "")):
continue
cands.append(s)
ranked = hub_adjusted_rank(cands, cfg.per_family_cap, attach_counts,
penalty_k=cfg.penalty_k, cap=cfg.cap,
min_sales=cfg.min_sales)
kept = 0
for s in ranked:
ref = s["reference"]
if ref in seen:
continue
seen.add(ref); kept += 1
attach_counts[ref] = attach_counts.get(ref, 0) + 1
blocks.append({"reference": ref, "description": s.get("description") or "",
"status": s.get("status") or "", "family_code": fam,
"display_sequence": len(blocks) + 1})
if len(blocks) >= cfg.max_total:
return blocks, pruned
if kept == 0:
pruned.append(fam)
return blocks, pruned
`
- [ ] Step 4: Run test to verify it passes
Run: venv\Scripts\python.exe -m pytest tests/test_complement_guardrails.py -v
Expected: PASS
- [ ] Step 5: Commit
`bash
git add core/complement_guardrails.py tests/test_complement_guardrails.py
git commit -m "feat(guardrails): build_guarded_blocks composes gates + hub-aware fill"
`
---
Task 7: v2 runner with qwen re-call and non-destructive persist
Files:
- Create:
scripts/refresh_complements_guardrails.py - Reference (mirror I/O patterns, do not modify):
scripts/refresh_complements_fallback.py(_ollama_call,make_llm,top_products_for_family,_persist_batch),scripts/export_marie_complements.py
Interfaces:
- Consumes:
build_guarded_blocks,GuardConfig(Task 6);resolve_fit_critical_families,section_filtered_vocab(Tasks 3, 5);build_fallback_prompt,parse_fallback_response(core.complement_fallback);top_products_for_family,_ollama_call(import fromscripts.refresh_complements_fallback). - Produces: CLI
venv\Scripts\python.exe -m scripts.refresh_complements_guardrails --country FR [--limit N] [--penalty-k 1.0] [--cap 200] [--recall/--no-recall]. Writessignal_type='zerocpr_v2',source_file='zerocpr-v2-2026-07-03'. Prints per-batch counts: anchors, pruned families, qwen re-calls, gaps, rows.
Interfaces detail — the runner's shape (mirror refresh_complements_fallback.py):
- [ ] Step 1: Write the module scaffold + pure-glue test
Create scripts/refresh_complements_guardrails.py:
`python
# -- coding: utf-8 --
"""zeroCPR v2 — re-dérive les compléments des ancres zeroCPR sous garde-fous.
Charge les familles choisies par le LLM (déjà stockées, signal_type='zerocpr'), applique build_guarded_blocks (gates + anti-hub), re-appelle qwen (vocabulaire filtré par section) pour les familles prunées, persiste signal_type='zerocpr_v2' (non destructif). Décision : docs/superpowers/specs/2026-07-03-zerocpr-guardrails-design.md
Run : venv\\Scripts\\python.exe -m scripts.refresh_complements_guardrails --country FR """ from __future__ import annotations import argparse, sys from collections import defaultdict from datetime import datetime, timezone from pathlib import Path _REPO = Path(__file__).resolve().parent.parent if str(_REPO) not in sys.path: sys.path.insert(0, str(_REPO)) from database.crm_db import CRMDatabase from database.timeseries_db import TimeseriesDatabase from core.complement_guardrails import build_guarded_blocks, GuardConfig from core.complement_gates import ( resolve_fit_critical_families, section_filtered_vocab, ) from core.complement_fallback import build_fallback_prompt, parse_fallback_response from scripts.refresh_complements_fallback import _ollama_call
SOURCE_FILE = "zerocpr-v2-2026-07-03" MATERIAL_SENSITIVE_LABELS = ["chaussure", "gant"] # familles sensibles matière
_sku_cache: dict = {} def load_family_skus(crm, ts, fc, country, sales_days=365): """Pool COMPLET des SKU actifs/in-périmètre d'une famille, enrichi (web_title, brand, brand_group, lyreco_brand, subcategory_code) + ventes 365j. NON tronqué : hub_adjusted_rank fait le tri + coupe AVEC démotion hub, donc il faut lui donner tout le pool (sinon le hub, toujours top-ventes, resterait dans le top-5). Caché.""" if fc in _sku_cache: return _sku_cache[fc] prods = crm.query("""SELECT product_reference ref, product_description descr, web_title, brand, brand_group, lyreco_brand, family_code, subcategory_code FROM ecom_products WHERE source_country=%s AND family_code=%s AND not_salable_flag='N' AND not_visible_flag='N'""", (country, fc)) refs = [p["ref"] for p in prods] sales = {} for i in range(0, len(refs), 5000): for r in ts.query(f"""SELECT product_reference ref, SUM(quantity) q FROM ecom_order_lines WHERE source_country=%s AND product_reference = ANY(%s) AND order_date >= (CURRENT_DATE - INTERVAL '{sales_days} days') GROUP BY 1""", (country, refs[i:i + 5000])): sales[r["ref"]] = int(r["q"] or 0) pool = [{"reference": p["ref"], "description": p["descr"] or "", "web_title": p["web_title"] or "", "brand": p["brand"], "brand_group": p["brand_group"], "lyreco_brand": p["lyreco_brand"], "family_code": p["family_code"], "subcategory_code": p["subcategory_code"], "sales": sales.get(p["ref"], 0), "not_salable": False, "not_visible": False} for p in prods] _sku_cache[fc] = pool return pool
def load_anchor_families(crm, country): """{anchor_ref: {"anchor": rec, "families": set(codes)}} depuis les picks zeroCPR.""" rows = crm.query(""" SELECT pc.anchor_reference a, ea.product_description descr, ea.section_code sec, ea.brand, ea.lyreco_brand, pc.family_code fc FROM product_complements pc JOIN ecom_products ea ON ea.product_reference=pc.anchor_reference AND ea.source_country=pc.source_country WHERE pc.source_country=%s AND pc.signal_type='zerocpr'""", (country,)) out = {} for r in rows: d = out.setdefault(r["a"], {"anchor": { "reference": r["a"], "description": r["descr"], "section_code": r["sec"], "brand": r["brand"], "lyreco_brand": r["lyreco_brand"]}, "families": set()}) d["families"].add(r["fc"]) return out
def load_family_meta(crm, country):
"""{family_code: {"section_code","family","material_sensitive"}} depuis la taxonomie."""
rows = crm.query("""SELECT DISTINCT family_code, family, section_code
FROM product_taxonomy WHERE source_country=%s AND family_code IS NOT NULL""", (country,))
meta = {}
for r in rows:
label = (r["family"] or "").lower()
meta[r["family_code"]] = {
"section_code": r["section_code"], "family": r["family"],
"material_sensitive": any(k in label for k in MATERIAL_SENSITIVE_LABELS)}
return meta
`
Add a tiny pure-glue test that load_family_meta's sensitivity flag is derived correctly — but since it needs DB, instead unit-test the sensitivity predicate inline by asserting the label rule in tests/test_complement_guardrails.py:
`python
# tests/test_complement_guardrails.py (append)
def test_material_sensitive_label_rule():
labels = ["chaussure", "gant"]
assert any(k in "chaussures de securite".lower() for k in labels) is True
assert any(k in "gants nitrile".lower() for k in labels) is True
assert any(k in "stylos bille".lower() for k in labels) is False
`
- [ ] Step 2: Run the glue test to verify it passes (scaffold imports resolve)
Run: venv\Scripts\python.exe -m pytest tests/test_complement_guardrails.py -k material_sensitive_label -v
Expected: PASS (and no ImportError from the new module's imports)
- [ ] Step 3: Implement
main()— build, re-call, persist
Append to scripts/refresh_complements_guardrails.py:
`python
def _persist(crm, country, anchor_ref, blocks):
"""DELETE ciblé (country, source_file, anchor) puis INSERT — non destructif vis-à-vis
des lignes 'zerocpr' (source_file différent). Même forme que _persist_batch."""
crm.execute("""DELETE FROM product_complements WHERE source_country=%s AND source_file=%s
AND anchor_reference=%s""", (country, SOURCE_FILE, anchor_ref))
if not blocks:
return 0
now = datetime.now(timezone.utc)
crm.executemany("""INSERT INTO product_complements
(anchor_reference, anchor_code_raw, complement_reference, complement_code_raw,
complement_description, display_sequence, family_code, signal_type, signal_label,
confidence, source_country, source_file, imported_at)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
ON CONFLICT (anchor_reference, complement_reference, source_country) DO NOTHING""",
[(anchor_ref, str(int(anchor_ref)), b["reference"], str(int(b["reference"])),
b["description"], b["display_sequence"], b["family_code"], "zerocpr_v2",
"guardrails-v2", 0.6, country, SOURCE_FILE, now) for b in blocks])
return len(blocks)
def main(country, limit, penalty_k, cap, recall): crm = CRMDatabase(); ts = TimeseriesDatabase() fam_meta = load_family_meta(crm, country) fit_critical = resolve_fit_critical_families( [{"family_code": k, "family": v["family"]} for k, v in fam_meta.items()]) vocab = [{"family_code": k, "family": v["family"], "section_code": v["section_code"]} for k, v in fam_meta.items()] valid = set(fam_meta) anchors = load_anchor_families(crm, country) items = list(anchors.items())[:limit] if limit else list(anchors.items()) cfg = GuardConfig(fit_critical=fit_critical, per_family_cap=3, max_total=10, penalty_k=penalty_k, cap=cap, min_sales=1) attach = {} tot_rows = tot_pruned = tot_recall = tot_gap = 0 print(f"anchors={len(items)} fit_critical={len(fit_critical)} penalty_k={penalty_k} " f"cap={cap} recall={recall}", flush=True)
for i, (aref, d) in enumerate(items, 1): anchor = d["anchor"] fam_skus = {fc: load_family_skus(crm, ts, fc, country) for fc in d["families"]} fmeta = {fc: fam_meta.get(fc, {"section_code": None, "material_sensitive": False}) for fc in d["families"]} blocks, pruned = build_guarded_blocks(anchor, fmeta, fam_skus, cfg, attach) tot_pruned += len(pruned)
if recall and pruned and len(blocks) < cfg.max_total: allowed = section_filtered_vocab(anchor["section_code"], vocab) if allowed: resp = _ollama_call("gpu-wsl", "qwen2.5:7b-instruct", build_fallback_prompt(anchor["description"], "", allowed)) new_fams = [c["family_code"] for c in parse_fallback_response(resp, valid) if c["family_code"] not in d["families"]] if new_fams: tot_recall += 1 fam_skus2 = {fc: load_family_skus(crm, ts, fc, country) for fc in new_fams} fmeta2 = {fc: fam_meta.get(fc, {"section_code": None, "material_sensitive": False}) for fc in new_fams} more, _ = build_guarded_blocks(anchor, fmeta2, fam_skus2, cfg, attach) # keep display_sequence contiguous for b in more: b["display_sequence"] = len(blocks) + 1 blocks.append(b) if len(blocks) >= cfg.max_total: break if not blocks: tot_gap += 1 tot_rows += _persist(crm, country, aref, blocks) if i % 200 == 0: print(f" {i}/{len(items)} | rows={tot_rows} pruned={tot_pruned} " f"recalls={tot_recall} gaps={tot_gap}", flush=True)
print(f"\nDONE anchors={len(items)} rows={tot_rows} pruned_families={tot_pruned} " f"qwen_recalls={tot_recall} gaps={tot_gap}")
if __name__ == "__main__":
ap = argparse.ArgumentParser()
ap.add_argument("--country", default="FR")
ap.add_argument("--limit", type=int, default=0)
ap.add_argument("--penalty-k", type=float, default=1.0)
ap.add_argument("--cap", type=int, default=200)
ap.add_argument("--recall", dest="recall", action="store_true", default=True)
ap.add_argument("--no-recall", dest="recall", action="store_false")
a = ap.parse_args()
main(a.country, a.limit or None, a.penalty_k, a.cap, a.recall)
`
- [ ] Step 4: Smoke-run on a small limit and verify it writes v2 rows
Run: venv\Scripts\python.exe -m scripts.refresh_complements_guardrails --country FR --limit 50 --no-recall
Expected: prints DONE anchors=50 rows= with N>0; then verify rows exist:
Run: venv\Scripts\python.exe -c "import sys; sys.path.insert(0,'.'); from database.crm_db import CRMDatabase; c=CRMDatabase(); print(c.query(\"SELECT COUNT(*) n, COUNT(DISTINCT anchor_reference) a FROM product_complements WHERE source_country='FR' AND signal_type='zerocpr_v2'\")[0])"
Expected: {'n': ; zeroCPR (v1) row count unchanged.
- [ ] Step 5: Commit
`bash
git add scripts/refresh_complements_guardrails.py tests/test_complement_guardrails.py
git commit -m "feat(guardrails): v2 runner with section-constrained qwen re-call + non-destructive persist"
`
---
Task 8: Full FR run + validation against success criteria + v1/v2 diff
Files:
- Create:
scripts/validate_zerocpr_v2.py - Reference:
docs/superpowers/specs/2026-07-03-zerocpr-guardrails-design.md(Success criteria)
Interfaces:
- Consumes: the persisted
zerocpr_v2rows. - Produces: a printed validation report asserting the spec's success criteria; exit non-zero if any hard criterion fails.
- [ ] Step 1: Write the validation script
`python
# scripts/validate_zerocpr_v2.py
# -- coding: utf-8 --
"""Valide zerocpr_v2 vs les critères de succès du spec + diff v1/v2. Exit!=0 si échec dur."""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from database.crm_db import CRMDatabase
C = "FR"; CAP = 200 crm = CRMDatabase() base = """FROM product_complements pc JOIN ecom_products ea ON ea.product_reference=pc.anchor_reference AND ea.source_country=pc.source_country JOIN ecom_products ec ON ec.product_reference=pc.complement_reference AND ec.source_country=pc.source_country WHERE pc.source_country=%s AND pc.signal_type=%s"""
def stats(sig): tot = crm.query(f"SELECT COUNT(*) n {base}", (C, sig))[0]["n"] xsec = crm.query(f"""SELECT COUNT(*) n {base} AND COALESCE(ea.section_code,'')<>'' AND COALESCE(ec.section_code,'')<>'' AND ea.section_code<>ec.section_code""", (C, sig))[0]["n"] skus = crm.query(f"SELECT COUNT(DISTINCT pc.complement_reference) n {base}", (C, sig))[0]["n"] maxhub = crm.query(f"""SELECT COALESCE(MAX(a),0) m FROM ( SELECT COUNT(DISTINCT pc.anchor_reference) a {base} GROUP BY pc.complement_reference) t""", (C, sig))[0]["m"] return {"pairs": tot, "xsec_pct": (100*xsec/tot if tot else 0), "distinct_skus": skus, "max_hub": maxhub}
v1, v2 = stats("zerocpr"), stats("zerocpr_v2") print(f"{'metric':16} {'v1':>12} {'v2':>12}") for k in ("pairs", "xsec_pct", "distinct_skus", "max_hub"): print(f" {k:16} {v1[k]:>12.1f} {v2[k]:>12.1f}" if k == "xsec_pct" else f" {k:16} {v1[k]:>12,} {v2[k]:>12,}")
fails = []
if v2["xsec_pct"] >= 15: fails.append(f"cross-section {v2['xsec_pct']:.0f}% >= 15%")
if v2["max_hub"] > CAP: fails.append(f"max hub {v2['max_hub']} > cap {CAP}")
if v2["distinct_skus"] <= v1["distinct_skus"]: fails.append("SKU pool did not widen")
print("\nRESULT:", "FAIL — " + "; ".join(fails) if fails else "PASS — all hard criteria met")
sys.exit(1 if fails else 0)
`
- [ ] Step 2: Run the full FR generation
Run: venv\Scripts\python.exe -m scripts.refresh_complements_guardrails --country FR
Expected: DONE anchors=~11000 rows=, qwen_recalls>0, gaps small. (Long run — foreground; qwen re-calls are the slow part. If reaped, re-run: it is idempotent per anchor via DELETE-by-source_file.)
- [ ] Step 3: Run validation
Run: venv\Scripts\python.exe -m scripts.validate_zerocpr_v2
Expected: RESULT: PASS — all hard criteria met (cross-section <15%, max hub ≤200, SKU pool wider than 671). If FAIL, tune --penalty-k / --cap and re-run Step 2.
- [ ] Step 4: Spot-check Marie's four cases
Run: `venv\Scripts\python.exe -c "import sys; sys.path.insert(0,'.'); from database.crm_db import CRMDatabase; c=CRMDatabase(); rows=c.query(\"\"\"SELECT ea.product_description a, ec.product_description b, pc.family_code FROM product_complements pc JOIN ecom_products ea ON ea.product_reference=pc.anchor_reference AND ea.source_country=pc.source_country JOIN ecom_products ec ON ec.product_reference=pc.complement_reference AND ec.source_country=pc.source_country WHERE pc.source_country='FR' AND pc.signal_type='zerocpr_v2' AND (ea.product_description ILIKE '%CHAUSSURE%' OR ea.product_description ILIKE '%AGRAFEUSE%' OR ea.product_description ILIKE '%STAPLER%' OR ea.product_description ILIKE '%CALCULAT%') LIMIT 40\"\"\"); [print(r['a'][:34],'->',r['b'][:34]) for r in rows]"` Expected: no shoe→body-deodorant, no competitor-stapler→Lyreco-staples, no Casio-adapter→Lyreco-calc. Record findings.
- [ ] Step 5: Commit
`bash
git add scripts/validate_zerocpr_v2.py
git commit -m "feat(guardrails): v2 validation against success criteria + v1/v2 diff"
`
---
Post-plan notes
- Conscious spec deviation — cooc branch omitted (YAGNI): the spec's "route zeroCPR through the existing
cooc(subcat lift) gate where co-occurrence data exists" branch is intentionally NOT implemented. zeroCPR anchors are, by construction, the population that lacked co-occurrence signal (that's why they fell to the fallback), so the cooc branch would almost never fire for them while adding a full order-line aggregation pass. Hub-IDF demotion is the mechanism for this population. Flag for Pierre at handoff; easy to add later if a subset does have cooc data. - Export/review-queue flip to v2 is a SEPARATE follow-up (not in this plan): once v2 validates and Marie's cases are confirmed fixed, point
scripts/export_marie_complements.pyandscripts/export_zerocpr_review_queue.pyatsignal_type IN ('zerocpr_v2', ...)instead of'zerocpr'. - gridiron's cross-check (mesh #3908) may tune
penalty_k/cap/ the penalty form in Task 4 — these are parameters, no structural change needed. - Deferred to Lyréco data-ask (per spec): model-level compatibility maps, a structured material/attribute table, a strategic-supplier brand list.
`