tagsearch 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,8 @@
1
+ Metadata-Version: 2.4
2
+ Name: tagsearch
3
+ Version: 1.0.0
4
+ Summary: In-memory tag search engine for e-commerce (DAWG + BM25 + hybrid ranking)
5
+ Requires-Python: >=3.11
6
+ Provides-Extra: dev
7
+ Requires-Dist: pytest>=8.0; extra == "dev"
8
+ Requires-Dist: pytest-cov; extra == "dev"
@@ -0,0 +1,64 @@
1
+ # tagsearch — Python Engine
2
+
3
+ In-memory tag search engine for e-commerce. DAWG + BM25 + 5-dimension hybrid ranking.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install -e ".[dev]"
9
+ ```
10
+
11
+ ## Quick Start
12
+
13
+ ```python
14
+ from tagsearch import SearchEngine, ProductMeta, ProductType, SearchPreferences
15
+
16
+ engine = SearchEngine(cache_size=200)
17
+ engine.load_catalog("../shared-assets/catalog.json")
18
+
19
+ # Recherche simple
20
+ results, status = engine.search("gaming")
21
+
22
+ # Recherche avec préférences
23
+ prefs = SearchPreferences(preferred_type=ProductType.DIGITAL, max_price=50.0)
24
+ results, status = engine.search("cuisine", prefs=prefs, fuzzy=True, max_dist=1)
25
+ ```
26
+
27
+ ## API
28
+
29
+ ### `SearchEngine.search(query, prefs, fuzzy, max_dist, top_k)`
30
+
31
+ | Paramètre | Type | Défaut | Description |
32
+ |-----------|------|--------|-------------|
33
+ | `query` | `str` | — | Requête utilisateur |
34
+ | `prefs` | `SearchPreferences` | `None` | Préférences de re-ranking |
35
+ | `fuzzy` | `bool` | `True` | Active la recherche floue (Levenshtein) |
36
+ | `max_dist`| `int` | `1` | Distance de Levenshtein maximale |
37
+ | `top_k` | `int` | `10` | Nombre de résultats retournés |
38
+
39
+ Retourne `(list[dict], status)` où `status` ∈ `{"CALCULÉ", "CACHÉ", "MISS"}`.
40
+
41
+ ### Score final (formule canonique)
42
+
43
+ ```
44
+ score = bm25_norm × quality_mult × type_factor × niche_boost × proximity × price_factor
45
+ ```
46
+
47
+ - `bm25_norm` = `min(bm25_raw / 10.0, 1.0)`, plancher à `0.05`
48
+ - `quality_mult` = `(bayesian_rating / 5.0) ** 1.5`
49
+ - `bayesian_rating` = `(count × rating + 3.5 × 10) / (count + 10)`
50
+
51
+ ## Tests
52
+
53
+ ```bash
54
+ pytest --cov=tagsearch
55
+ ```
56
+
57
+ ## Benchmark attendu
58
+
59
+ | Opération | Complexité | Temps typique (10k tags) |
60
+ |-----------|-----------|--------------------------|
61
+ | Autocomplete | O(L) | < 0.1 ms |
62
+ | Fuzzy search (max_dist=1) | O(L × 5%) | < 1 ms |
63
+ | Scoring 100 candidats | O(n) | < 0.5 ms |
64
+ | Cache HIT | O(1) | < 0.01 ms |
@@ -0,0 +1,21 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "tagsearch"
7
+ version = "1.0.0"
8
+ description = "In-memory tag search engine for e-commerce (DAWG + BM25 + hybrid ranking)"
9
+ requires-python = ">=3.11"
10
+ dependencies = []
11
+
12
+ [project.optional-dependencies]
13
+ dev = ["pytest>=8.0", "pytest-cov"]
14
+
15
+ [tool.pytest.ini_options]
16
+ testpaths = ["tests"]
17
+ pythonpath = ["."]
18
+
19
+ [tool.setuptools.packages.find]
20
+ where = ["."]
21
+ include = ["tagsearch*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,12 @@
1
+ """tagsearch — In-memory tag search engine for e-commerce."""
2
+ from .engine import SearchEngine
3
+ from .models import ProductMeta, ProductType, SearchPreferences
4
+ from .scoring import calculate_final_score
5
+
6
+ __all__ = [
7
+ "SearchEngine",
8
+ "ProductMeta",
9
+ "ProductType",
10
+ "SearchPreferences",
11
+ "calculate_final_score",
12
+ ]
@@ -0,0 +1,242 @@
1
+ """
2
+ tagsearch.engine
3
+ ================
4
+ SearchEngine : point d'entrée public de la bibliothèque.
5
+ Orchestre DAWG, BM25Index, CompressedPostingsList, LRUCache et calculate_final_score.
6
+ """
7
+
8
+ from __future__ import annotations
9
+ import json
10
+ import logging
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ from .models import ProductMeta, ProductType, SearchPreferences
15
+ from .scoring import BM25Index, calculate_final_score
16
+ from .structures import DAWG, CompressedPostingsList, LRUCache
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ class SearchEngine:
22
+ """
23
+ Moteur de recherche in-memory pour tags e-commerce.
24
+
25
+ Architecture :
26
+ DAWG → index des tags (autocomplete + fuzzy Levenshtein)
27
+ CompressedPostingsList → inverted index compressé (delta + VarInt)
28
+ BM25Index → scoring textuel
29
+ calculate_final_score → re-ranking 5 dimensions
30
+ LRUCache → cache des résultats de recherche
31
+
32
+ Example:
33
+ >>> engine = SearchEngine()
34
+ >>> engine.load_catalog("shared-assets/catalog.json")
35
+ >>> results, status = engine.search("gaming", fuzzy=True)
36
+
37
+ Args:
38
+ cache_size: Capacité du cache LRU (nombre de requêtes mémorisées).
39
+ """
40
+
41
+ def __init__(self, cache_size: int = 200) -> None:
42
+ self._all_tags: list[str] = []
43
+ self.dawg: DAWG = DAWG()
44
+ self.inverted_index: dict[str, CompressedPostingsList] = {}
45
+ self.catalog: dict[int, ProductMeta] = {}
46
+ self.bm25: BM25Index = BM25Index()
47
+ self.cache: LRUCache = LRUCache(capacity=cache_size)
48
+ logger.info("SearchEngine initialisé (cache_size=%d)", cache_size)
49
+
50
+ # ── Chargement ──────────────────────────────────────────────────────────
51
+
52
+ def load_catalog(self, path: str | Path) -> None:
53
+ """
54
+ Charge un catalogue depuis un fichier JSON (format shared-assets/catalog.json).
55
+
56
+ Args:
57
+ path: Chemin vers le fichier JSON.
58
+
59
+ Raises:
60
+ FileNotFoundError: Si le fichier n'existe pas.
61
+ ValueError: Si le JSON est malformé ou un produit est invalide.
62
+ """
63
+ path = Path(path)
64
+ if not path.exists():
65
+ raise FileNotFoundError(f"Catalogue introuvable : {path}")
66
+ try:
67
+ data = json.loads(path.read_text(encoding="utf-8"))
68
+ except json.JSONDecodeError as exc:
69
+ raise ValueError(f"JSON malformé dans {path} : {exc}") from exc
70
+
71
+ products = data.get("products", [])
72
+ if not products:
73
+ logger.warning("Catalogue vide : aucun produit chargé depuis %s", path)
74
+ return
75
+
76
+ for raw in products:
77
+ try:
78
+ meta = ProductMeta(
79
+ name = raw["name"],
80
+ product_type = ProductType.from_str(raw["type"]),
81
+ niche = raw["niche"],
82
+ price = float(raw["price"]),
83
+ location = raw.get("location"),
84
+ avg_rating = float(raw["avg_rating"]),
85
+ review_count = int(raw["review_count"]),
86
+ tags = [str(t) for t in raw["tags"]],
87
+ )
88
+ self.add_product(int(raw["id"]), meta)
89
+ except (KeyError, TypeError, ValueError) as exc:
90
+ raise ValueError(f"Produit invalide (id={raw.get('id', '?')}) : {exc}") from exc
91
+
92
+ logger.info("Catalogue chargé : %d produits depuis %s", len(products), path)
93
+
94
+ def add_product(self, product_id: int, meta: ProductMeta) -> None:
95
+ """
96
+ Indexe un produit.
97
+
98
+ Args:
99
+ product_id: Identifiant unique (entier positif).
100
+ meta: Métadonnées complètes du produit.
101
+
102
+ Raises:
103
+ ValueError: Si product_id est négatif ou déjà présent.
104
+ """
105
+ if product_id < 0:
106
+ raise ValueError(f"product_id doit être >= 0, reçu : {product_id}")
107
+ if product_id in self.catalog:
108
+ logger.warning("Produit %d déjà indexé — écrasement.", product_id)
109
+
110
+ self.catalog[product_id] = meta
111
+
112
+ for tag in meta.tags:
113
+ tag_lower = tag.lower()
114
+ if tag_lower not in self.inverted_index:
115
+ self.inverted_index[tag_lower] = CompressedPostingsList()
116
+ self._all_tags.append(tag_lower)
117
+ self.inverted_index[tag_lower].add(product_id)
118
+
119
+ self.bm25.index(product_id, meta.tags)
120
+
121
+ for pl in self.inverted_index.values():
122
+ pl.flush()
123
+ self.dawg = DAWG.build(self._all_tags)
124
+ self.cache.invalidate()
125
+ logger.debug("Produit %d indexé : '%s'", product_id, meta.name)
126
+
127
+ # ── Recherche ────────────────────────────────────────────────────────────
128
+
129
+ def search(
130
+ self,
131
+ query : str,
132
+ prefs : SearchPreferences | None = None,
133
+ fuzzy : bool = True,
134
+ max_dist : int = 1,
135
+ top_k : int = 10,
136
+ ) -> tuple[list[dict], str]:
137
+ """
138
+ Recherche multidimensionnelle in-memory.
139
+
140
+ Flux :
141
+ 1. DAWG fuzzy/autocomplete → matched_tags
142
+ 2. CompressedPostingsList → candidate_ids
143
+ 3. BM25Index.score() → bm25_raw par produit
144
+ 4. calculate_final_score() → score final (5 dimensions)
145
+ 5. Tri décroissant + top_k + cache LRU
146
+
147
+ Args:
148
+ query: Requête utilisateur (chaîne brute, normalisée en interne).
149
+ prefs: Préférences de re-ranking. None = comportement neutre.
150
+ fuzzy: Si True, utilise la recherche floue (Levenshtein + pruning).
151
+ max_dist: Distance de Levenshtein maximale (1 = 1 faute de frappe).
152
+ top_k: Nombre maximum de résultats retournés.
153
+
154
+ Returns:
155
+ Tuple (results, status) où :
156
+ results : liste de dicts triés par score décroissant.
157
+ Champs : id, name, type, niche, price, rating, reviews, score.
158
+ status : "CACHÉ" | "CALCULÉ" | "MISS".
159
+
160
+ Raises:
161
+ ValueError: Si query est vide ou max_dist < 0.
162
+ """
163
+ if not query or not query.strip():
164
+ raise ValueError("La requête ne peut pas être vide.")
165
+ if max_dist < 0:
166
+ raise ValueError(f"max_dist doit être >= 0, reçu : {max_dist}")
167
+ if not self.catalog:
168
+ logger.warning("search() appelé sur un index vide.")
169
+ return [], "MISS"
170
+
171
+ if prefs is None:
172
+ prefs = SearchPreferences()
173
+
174
+ cache_key = f"{query.strip().lower()}|{prefs}"
175
+ cached = self.cache.get(cache_key)
176
+ if cached is not None:
177
+ logger.debug("Cache HIT pour '%s'", query)
178
+ return cached, "CACHÉ"
179
+
180
+ query_lower = query.strip().lower()
181
+
182
+ # 1. Tags via DAWG
183
+ try:
184
+ matched_tags = (
185
+ self.dawg.fuzzy_search(query_lower, max_dist)
186
+ if fuzzy else
187
+ self.dawg.autocomplete(query_lower)
188
+ )
189
+ except Exception as exc:
190
+ logger.error("Erreur DAWG pour '%s' : %s", query, exc)
191
+ return [], "MISS"
192
+
193
+ if not matched_tags:
194
+ logger.debug("Aucun tag trouvé pour '%s'", query)
195
+ return [], "MISS"
196
+
197
+ # 2. Candidats via inverted index
198
+ candidate_ids: set[int] = set()
199
+ for tag in matched_tags:
200
+ if tag in self.inverted_index:
201
+ try:
202
+ candidate_ids.update(self.inverted_index[tag].decompress())
203
+ except Exception as exc:
204
+ logger.error("Erreur décompression postings '%s' : %s", tag, exc)
205
+
206
+ if not candidate_ids:
207
+ return [], "MISS"
208
+
209
+ # 3. BM25 + re-ranking
210
+ results: list[dict] = []
211
+ for pid in candidate_ids:
212
+ meta = self.catalog.get(pid)
213
+ if meta is None:
214
+ logger.warning("product_id %d dans l'index mais absent du catalogue.", pid)
215
+ continue
216
+ try:
217
+ bm25_raw = self.bm25.score(pid, matched_tags)
218
+ final_score = calculate_final_score(pid, meta, bm25_raw, prefs)
219
+ except Exception as exc:
220
+ logger.error("Erreur scoring produit %d : %s", pid, exc)
221
+ continue
222
+
223
+ if final_score is None:
224
+ continue
225
+
226
+ results.append({
227
+ "id" : pid,
228
+ "name" : meta.name,
229
+ "type" : meta.product_type.name,
230
+ "niche" : meta.niche,
231
+ "price" : meta.price,
232
+ "rating" : meta.avg_rating,
233
+ "reviews": meta.review_count,
234
+ "score" : final_score,
235
+ })
236
+
237
+ results.sort(key=lambda x: x["score"], reverse=True)
238
+ final_list = results[:top_k]
239
+
240
+ self.cache.put(cache_key, final_list)
241
+ logger.info("Recherche '%s' : %d résultats (%d candidats)", query, len(final_list), len(candidate_ids))
242
+ return final_list, "CALCULÉ"
@@ -0,0 +1,81 @@
1
+ """
2
+ tagsearch.models
3
+ ================
4
+ Structures de données métier : ProductType, ProductMeta, SearchPreferences.
5
+ Tous les dataclasses utilisent slots=True (Python 3.10+) pour minimiser la RAM.
6
+ """
7
+
8
+ from __future__ import annotations
9
+ from dataclasses import dataclass
10
+ from enum import Enum
11
+
12
+
13
+ class ProductType(Enum):
14
+ """Type de produit. Encodé comme entier pour comparaison O(1)."""
15
+ PHYSICAL = 0
16
+ DIGITAL = 1
17
+
18
+ @classmethod
19
+ def from_str(cls, value: str) -> "ProductType":
20
+ """Parse depuis une chaîne JSON ('PHYSICAL' | 'DIGITAL')."""
21
+ try:
22
+ return cls[value.upper()]
23
+ except KeyError:
24
+ raise ValueError(f"ProductType invalide : '{value}'. Attendu : PHYSICAL | DIGITAL")
25
+
26
+
27
+ @dataclass(slots=True)
28
+ class ProductMeta:
29
+ """
30
+ Métadonnées complètes d'un produit.
31
+
32
+ Args:
33
+ name: Nom affiché du produit.
34
+ product_type: PHYSICAL ou DIGITAL.
35
+ niche: Catégorie (gaming, cuisine, beauté…). Interné via sys.intern.
36
+ price: Prix en euros (float).
37
+ location: Zone de livraison. None pour les produits digitaux.
38
+ avg_rating: Note moyenne des avis (0.0–5.0).
39
+ review_count: Nombre d'avis. Pondère la fiabilité du rating (Bayesian).
40
+ tags: Liste de tags indexés.
41
+ """
42
+ name : str
43
+ product_type : ProductType
44
+ niche : str
45
+ price : float
46
+ location : str | None
47
+ avg_rating : float
48
+ review_count : int
49
+ tags : list[str]
50
+
51
+ def __post_init__(self) -> None:
52
+ import sys
53
+ self.niche = sys.intern(self.niche.lower())
54
+ if not 0.0 <= self.avg_rating <= 5.0:
55
+ raise ValueError(f"avg_rating doit être dans [0.0, 5.0], reçu : {self.avg_rating}")
56
+ if self.review_count < 0:
57
+ raise ValueError(f"review_count doit être >= 0, reçu : {self.review_count}")
58
+ if self.price < 0:
59
+ raise ValueError(f"price doit être >= 0, reçu : {self.price}")
60
+
61
+
62
+ @dataclass(slots=True)
63
+ class SearchPreferences:
64
+ """
65
+ Préférences utilisateur pour le re-ranking multidimensionnel.
66
+ Toutes les valeurs sont optionnelles (None = neutre).
67
+
68
+ Args:
69
+ preferred_type: Filtre doux sur le type (PHYSICAL/DIGITAL).
70
+ preferred_niche: Boost +20% si la niche correspond.
71
+ max_price: Filtre dur : exclut les produits au-dessus.
72
+ min_price: Filtre dur : exclut les produits en-dessous.
73
+ user_location: Bonus +15% si la zone de livraison correspond.
74
+ min_rating: Filtre dur : exclut les produits en-dessous de ce seuil.
75
+ """
76
+ preferred_type : ProductType | None = None
77
+ preferred_niche : str | None = None
78
+ max_price : float | None = None
79
+ min_price : float | None = None
80
+ user_location : str | None = None
81
+ min_rating : float = 0.0
@@ -0,0 +1,177 @@
1
+ """
2
+ tagsearch.scoring
3
+ =================
4
+ BM25Index + calculate_final_score.
5
+
6
+ Formules canoniques documentées ici — source de vérité pour le portage TS/PHP.
7
+ Toute modification doit être répercutée dans shared-assets/catalog.json
8
+ (section scoring_constants) et dans les tests cross-langage.
9
+ """
10
+
11
+ from __future__ import annotations
12
+ import logging
13
+ import math
14
+ from collections import defaultdict
15
+
16
+ from .models import ProductMeta, ProductType, SearchPreferences
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ # ── Constantes de scoring (synchronisées avec shared-assets/catalog.json) ───
21
+ BM25_K1 = 1.5
22
+ BM25_B = 0.75
23
+ BM25_CAP = 10.0 # Plafond de normalisation BM25 → score dans [0,1]
24
+ BM25_FLOOR = 0.05 # Plancher : tag très fréquent reste visible
25
+ QUALITY_EXPONENT = 1.5 # Courbe convexe : pénalise sévèrement les notes < 3/5
26
+ PRIOR_MEAN = 3.5 # Note a priori marché e-commerce
27
+ PRIOR_WEIGHT = 10.0 # Équivalent 10 avis fictifs (lissage Bayesian)
28
+ TYPE_MISMATCH = 0.1 # Facteur si type ne correspond pas (filtre doux)
29
+ NICHE_BOOST = 1.2 # +20% si niche correspond
30
+ PROXIMITY_BONUS = 1.15 # +15% si zone de livraison correspond
31
+ PRICE_BUDGET_BOOST = 1.1 # +10% si prix ≤ 50% du budget max
32
+
33
+
34
+ class BM25Index:
35
+ """
36
+ BM25 (Okapi BM25) in-memory sur les tags produits.
37
+
38
+ Formule IDF :
39
+ IDF(q) = log( (N - df(q) + 0.5) / (df(q) + 0.5) + 1 )
40
+ Le +1 évite les valeurs négatives pour les termes très fréquents.
41
+
42
+ Formule TF normalisée :
43
+ TF_norm(q, d) = tf(q,d) × (K1+1) / (tf(q,d) + K1×(1 - B + B×dl/avgdl))
44
+
45
+ Score BM25(d, Q) = Σ IDF(qi) × TF_norm(qi, d)
46
+ """
47
+
48
+ def __init__(self) -> None:
49
+ self.tf: dict[str, dict[int, int]] = defaultdict(lambda: defaultdict(int))
50
+ self.df: dict[str, int] = defaultdict(int)
51
+ self.dl: dict[int, int] = {}
52
+ self.N: int = 0
53
+ self.avgdl: float = 0.0
54
+
55
+ def index(self, product_id: int, tags: list[str]) -> None:
56
+ """
57
+ Indexe les tags d'un produit.
58
+
59
+ Args:
60
+ product_id: Identifiant unique du produit.
61
+ tags: Liste de tags (non normalisés, la normalisation est interne).
62
+ """
63
+ tags_lower = [t.lower() for t in tags]
64
+ self.dl[product_id] = len(tags_lower)
65
+ self.N += 1
66
+ self.avgdl = sum(self.dl.values()) / self.N
67
+ for tag in tags_lower:
68
+ self.tf[tag][product_id] += 1
69
+ if self.tf[tag][product_id] == 1:
70
+ self.df[tag] += 1
71
+ logger.debug("BM25: produit %d indexé (%d tags, avgdl=%.2f)", product_id, len(tags_lower), self.avgdl)
72
+
73
+ def score(self, product_id: int, query_tags: list[str]) -> float:
74
+ """
75
+ Calcule le score BM25 brut pour un produit et une liste de tags requête.
76
+
77
+ Args:
78
+ product_id: ID du produit à scorer.
79
+ query_tags: Tags issus de la recherche DAWG (matched_tags).
80
+
81
+ Returns:
82
+ Score BM25 brut (non normalisé, non plafonné). Typiquement dans [0, 15].
83
+ """
84
+ if self.N == 0 or self.avgdl == 0:
85
+ return 0.0
86
+ total = 0.0
87
+ dl_d = self.dl.get(product_id, 0)
88
+ for tag in query_tags:
89
+ tag = tag.lower()
90
+ tf_val = self.tf[tag].get(product_id, 0)
91
+ if tf_val == 0: continue
92
+ df_val = self.df.get(tag, 0)
93
+ idf = math.log((self.N - df_val + 0.5) / (df_val + 0.5) + 1)
94
+ norm = tf_val * (BM25_K1 + 1) / (tf_val + BM25_K1 * (1 - BM25_B + BM25_B * dl_d / self.avgdl))
95
+ total += idf * norm
96
+ return total
97
+
98
+
99
+ def calculate_final_score(
100
+ product_id: int,
101
+ meta: ProductMeta,
102
+ bm25_raw: float,
103
+ prefs: SearchPreferences,
104
+ ) -> float | None:
105
+ """
106
+ Re-ranking multidimensionnel Phase 2.
107
+
108
+ Formule (multiplicative) :
109
+ final = bm25_norm × quality_mult × type_factor × niche_boost
110
+ × proximity_bonus × price_factor
111
+
112
+ Args:
113
+ product_id: ID du produit (utilisé pour le logging).
114
+ meta: Métadonnées complètes du produit.
115
+ bm25_raw: Score BM25 brut retourné par BM25Index.score().
116
+ prefs: Préférences utilisateur pour le re-ranking.
117
+
118
+ Returns:
119
+ float arrondi à 4 décimales, ou None si le produit est exclu par un filtre dur.
120
+
121
+ Raises:
122
+ TypeError: Si meta ou prefs ne sont pas du bon type.
123
+ """
124
+ if not isinstance(meta, ProductMeta):
125
+ raise TypeError(f"meta doit être ProductMeta, reçu : {type(meta)}")
126
+ if not isinstance(prefs, SearchPreferences):
127
+ raise TypeError(f"prefs doit être SearchPreferences, reçu : {type(prefs)}")
128
+
129
+ # ── Filtres durs ────────────────────────────────────────────────────────
130
+ if prefs.max_price is not None and meta.price > prefs.max_price:
131
+ logger.debug("Produit %d exclu : prix %.2f > max %.2f", product_id, meta.price, prefs.max_price)
132
+ return None
133
+ if prefs.min_price is not None and meta.price < prefs.min_price:
134
+ logger.debug("Produit %d exclu : prix %.2f < min %.2f", product_id, meta.price, prefs.min_price)
135
+ return None
136
+ if meta.avg_rating < prefs.min_rating:
137
+ logger.debug("Produit %d exclu : rating %.1f < min %.1f", product_id, meta.avg_rating, prefs.min_rating)
138
+ return None
139
+
140
+ # ── Dim 1 : BM25 normalisé ──────────────────────────────────────────────
141
+ bm25_norm = max(min(bm25_raw / BM25_CAP, 1.0), BM25_FLOOR)
142
+
143
+ # ── Dim 2 : Qualité Bayesian ────────────────────────────────────────────
144
+ bayesian = (meta.review_count * meta.avg_rating + PRIOR_MEAN * PRIOR_WEIGHT) / (meta.review_count + PRIOR_WEIGHT)
145
+ quality_mult = (bayesian / 5.0) ** QUALITY_EXPONENT
146
+
147
+ # ── Dim 3 : Type + Niche ────────────────────────────────────────────────
148
+ type_factor = TYPE_MISMATCH if (
149
+ prefs.preferred_type is not None and meta.product_type != prefs.preferred_type
150
+ ) else 1.0
151
+
152
+ niche_factor = NICHE_BOOST if (
153
+ prefs.preferred_niche is not None
154
+ and meta.niche == prefs.preferred_niche.lower()
155
+ ) else 1.0
156
+
157
+ # ── Dim 4 : Proximité ───────────────────────────────────────────────────
158
+ if meta.location is None:
159
+ proximity = 1.0
160
+ elif prefs.user_location is not None and meta.location.lower() == prefs.user_location.lower():
161
+ proximity = PROXIMITY_BONUS
162
+ else:
163
+ proximity = 1.0
164
+
165
+ # ── Dim 5 : Prix (filtre doux) ──────────────────────────────────────────
166
+ if prefs.max_price is not None and prefs.max_price > 0:
167
+ price_factor = PRICE_BUDGET_BOOST if (meta.price / prefs.max_price) <= 0.5 else 1.0
168
+ else:
169
+ price_factor = 1.0
170
+
171
+ final = bm25_norm * quality_mult * type_factor * niche_factor * proximity * price_factor
172
+ result = round(final, 4)
173
+ logger.debug(
174
+ "Produit %d | bm25=%.4f qmult=%.4f type=%.1f niche=%.1f prox=%.2f price=%.1f → %.4f",
175
+ product_id, bm25_norm, quality_mult, type_factor, niche_factor, proximity, price_factor, result
176
+ )
177
+ return result
@@ -0,0 +1,360 @@
1
+ """
2
+ tagsearch.structures
3
+ ====================
4
+ Structures de données bas niveau (Phase 1) :
5
+ - TrieNode / Trie : __slots__ + tableau fixe O(1)
6
+ - DAWGNode / DAWG : FST simplifié, partage de suffixes
7
+ - VarInt : encodage variable-length integer
8
+ - CompressedPostingsList: delta-encoding + VarInt
9
+ - LRUCache : OrderedDict-based O(1) get/put
10
+ """
11
+
12
+ from __future__ import annotations
13
+ from collections import OrderedDict
14
+ import logging
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ # ── Alphabet ────────────────────────────────────────────────────────────────
19
+ ALPHA_SIZE = 38 # a-z(0-25), 0-9(26-35), -(36), espace(37)
20
+
21
+
22
+ def _char_index(c: str) -> int:
23
+ if 'a' <= c <= 'z': return ord(c) - ord('a')
24
+ if '0' <= c <= '9': return ord(c) - ord('0') + 26
25
+ if c == '-': return 36
26
+ if c == ' ': return 37
27
+ return -1
28
+
29
+
30
+ def _index_to_char(idx: int) -> str:
31
+ if idx <= 25: return chr(ord('a') + idx)
32
+ if idx <= 35: return chr(ord('0') + idx - 26)
33
+ if idx == 36: return '-'
34
+ return ' '
35
+
36
+
37
+ # ── Trie (Phase 1.1 + 1.2) ──────────────────────────────────────────────────
38
+
39
+ class TrieNode:
40
+ """Nœud Trie : __slots__ + tableau fixe de 38 pointeurs (lazy init)."""
41
+ __slots__ = ('children', 'is_terminal', 'tag')
42
+
43
+ def __init__(self) -> None:
44
+ self.children: list | None = None
45
+ self.is_terminal: bool = False
46
+ self.tag: str | None = None
47
+
48
+ def get_child(self, c: str) -> TrieNode | None:
49
+ if self.children is None: return None
50
+ idx = _char_index(c)
51
+ return None if idx == -1 else self.children[idx]
52
+
53
+ def set_child(self, c: str, node: TrieNode) -> None:
54
+ if self.children is None:
55
+ self.children = [None] * ALPHA_SIZE
56
+ idx = _char_index(c)
57
+ if idx != -1:
58
+ self.children[idx] = node
59
+
60
+ def iter_children(self):
61
+ if self.children is None: return
62
+ for idx, child in enumerate(self.children):
63
+ if child is not None: yield idx, child
64
+
65
+
66
+ class Trie:
67
+ """Trie optimisé mémoire. Utilisé comme buffer d'écriture avant rebuild DAWG."""
68
+
69
+ def __init__(self) -> None:
70
+ self.root = TrieNode()
71
+
72
+ def insert(self, word: str) -> None:
73
+ node = self.root
74
+ for char in word.lower():
75
+ child = node.get_child(char)
76
+ if child is None:
77
+ child = TrieNode()
78
+ node.set_child(char, child)
79
+ node = child
80
+ node.is_terminal = True
81
+ node.tag = word
82
+
83
+ def autocomplete(self, prefix: str) -> list[str]:
84
+ node = self.root
85
+ for char in prefix.lower():
86
+ node = node.get_child(char)
87
+ if node is None: return []
88
+ results: list[str] = []
89
+ self._dfs(node, results)
90
+ return results
91
+
92
+ def _dfs(self, node: TrieNode, results: list[str]) -> None:
93
+ if node.is_terminal: results.append(node.tag) # type: ignore[arg-type]
94
+ for _, child in node.iter_children():
95
+ self._dfs(child, results)
96
+
97
+ def fuzzy_search(self, query: str, max_distance: int = 1) -> list[str]:
98
+ """Levenshtein DP avec pruning : coupe les branches si min(row) > max_distance."""
99
+ query = query.lower()
100
+ results: list[tuple[str, int]] = []
101
+ init_row = list(range(len(query) + 1))
102
+ for idx, child in self.root.iter_children():
103
+ self._fuzzy_dfs(child, _index_to_char(idx), query, init_row, max_distance, results)
104
+ results.sort(key=lambda x: x[1])
105
+ return [tag for tag, _ in results]
106
+
107
+ def _fuzzy_dfs(self, node: TrieNode, char: str, query: str,
108
+ prev_row: list[int], max_dist: int,
109
+ results: list[tuple[str, int]]) -> None:
110
+ cur = [prev_row[0] + 1]
111
+ for col in range(1, len(query) + 1):
112
+ cur.append(min(cur[col-1]+1, prev_row[col]+1,
113
+ prev_row[col-1] + (0 if query[col-1] == char else 1)))
114
+ if min(cur) > max_dist: return
115
+ if cur[-1] <= max_dist and node.is_terminal:
116
+ results.append((node.tag, cur[-1])) # type: ignore[arg-type]
117
+ for idx, child in node.iter_children():
118
+ self._fuzzy_dfs(child, _index_to_char(idx), query, cur, max_dist, results)
119
+
120
+
121
+ # ── DAWG (Phase 1.3) ────────────────────────────────────────────────────────
122
+
123
+ class DAWGNode:
124
+ """Nœud DAWG avec identité structurelle pour déduplication de suffixes."""
125
+ __slots__ = ('children', 'is_terminal', 'tag', 'frozen', '_hash_cache')
126
+
127
+ def __init__(self) -> None:
128
+ self.children: dict[str, DAWGNode] = {}
129
+ self.is_terminal: bool = False
130
+ self.tag: str | None = None
131
+ self.frozen: bool = False
132
+ self._hash_cache: int | None = None
133
+
134
+ def structural_key(self) -> tuple:
135
+ return (self.is_terminal, self.tag,
136
+ tuple(sorted((c, id(n)) for c, n in self.children.items())))
137
+
138
+ def __hash__(self) -> int:
139
+ if self._hash_cache is None:
140
+ self._hash_cache = hash(self.structural_key())
141
+ return self._hash_cache
142
+
143
+ def __eq__(self, other: object) -> bool:
144
+ return isinstance(other, DAWGNode) and self.structural_key() == other.structural_key()
145
+
146
+
147
+ class DAWG:
148
+ """
149
+ DAWG (Directed Acyclic Word Graph) via algorithme de Daciuk.
150
+ Partage les suffixes communs → 30-50% moins de nœuds vs Trie sur corpus e-commerce.
151
+ IMMUTABLE après finish(). Utiliser Trie comme buffer pour les insertions dynamiques.
152
+ """
153
+
154
+ def __init__(self) -> None:
155
+ self.root = DAWGNode()
156
+ self._minimized: dict[tuple, DAWGNode] = {}
157
+ self._last_word: str = ""
158
+ self._unchecked: list[tuple[DAWGNode, str, DAWGNode]] = []
159
+
160
+ def _minimize(self, down_to: int) -> None:
161
+ for i in range(len(self._unchecked) - 1, down_to - 1, -1):
162
+ parent, char, child = self._unchecked[i]
163
+ key = child.structural_key()
164
+ if key in self._minimized:
165
+ parent.children[char] = self._minimized[key]
166
+ else:
167
+ child.frozen = True
168
+ self._minimized[key] = child
169
+ self._unchecked.pop()
170
+
171
+ def insert(self, word: str) -> None:
172
+ word = word.lower()
173
+ if word <= self._last_word and self._last_word:
174
+ raise ValueError(f"DAWG: insertion hors ordre — '{word}' après '{self._last_word}'.")
175
+ common = 0
176
+ for i, (c1, c2) in enumerate(zip(word, self._last_word)):
177
+ if c1 != c2: break
178
+ common = i + 1
179
+ else:
180
+ common = min(len(word), len(self._last_word))
181
+ self._minimize(common)
182
+ node = self.root if not self._unchecked else self._unchecked[-1][2]
183
+ for char in word[common:]:
184
+ new_node = DAWGNode()
185
+ node.children[char] = new_node
186
+ self._unchecked.append((node, char, new_node))
187
+ node = new_node
188
+ node.is_terminal = True
189
+ node.tag = word
190
+ self._last_word = word
191
+
192
+ def finish(self) -> None:
193
+ self._minimize(0)
194
+
195
+ @classmethod
196
+ def build(cls, tags: list[str]) -> DAWG:
197
+ """Construit un DAWG depuis une liste de tags (tri automatique)."""
198
+ dawg = cls()
199
+ for tag in sorted(set(t.lower() for t in tags)):
200
+ dawg.insert(tag)
201
+ dawg.finish()
202
+ logger.debug("DAWG construit : %d nœuds uniques", dawg.node_count())
203
+ return dawg
204
+
205
+ def contains(self, word: str) -> bool:
206
+ node = self.root
207
+ for char in word.lower():
208
+ node = node.children.get(char) # type: ignore[assignment]
209
+ if node is None: return False
210
+ return node.is_terminal # type: ignore[union-attr]
211
+
212
+ def autocomplete(self, prefix: str) -> list[str]:
213
+ node = self.root
214
+ for char in prefix.lower():
215
+ node = node.children.get(char) # type: ignore[assignment]
216
+ if node is None: return []
217
+ results: list[str] = []
218
+ self._dfs(node, results) # type: ignore[arg-type]
219
+ return results
220
+
221
+ def _dfs(self, node: DAWGNode, results: list[str]) -> None:
222
+ if node.is_terminal: results.append(node.tag) # type: ignore[arg-type]
223
+ for child in node.children.values():
224
+ self._dfs(child, results)
225
+
226
+ def fuzzy_search(self, query: str, max_distance: int = 1) -> list[str]:
227
+ query = query.lower()
228
+ results: list[tuple[str, int]] = []
229
+ init_row = list(range(len(query) + 1))
230
+ for char, child in self.root.children.items():
231
+ self._fuzzy_dfs(child, char, query, init_row, max_distance, results)
232
+ results.sort(key=lambda x: x[1])
233
+ return [tag for tag, _ in results]
234
+
235
+ def _fuzzy_dfs(self, node: DAWGNode, char: str, query: str,
236
+ prev_row: list[int], max_dist: int,
237
+ results: list[tuple[str, int]]) -> None:
238
+ cur = [prev_row[0] + 1]
239
+ for col in range(1, len(query) + 1):
240
+ cur.append(min(cur[col-1]+1, prev_row[col]+1,
241
+ prev_row[col-1] + (0 if query[col-1] == char else 1)))
242
+ if min(cur) > max_dist: return
243
+ if cur[-1] <= max_dist and node.is_terminal:
244
+ results.append((node.tag, cur[-1])) # type: ignore[arg-type]
245
+ for next_char, child in node.children.items():
246
+ self._fuzzy_dfs(child, next_char, query, cur, max_dist, results)
247
+
248
+ def node_count(self) -> int:
249
+ visited: set[int] = set()
250
+ self._count(self.root, visited)
251
+ return len(visited)
252
+
253
+ def _count(self, node: DAWGNode, visited: set[int]) -> None:
254
+ if id(node) in visited: return
255
+ visited.add(id(node))
256
+ for child in node.children.values():
257
+ self._count(child, visited)
258
+
259
+
260
+ # ── VarInt + CompressedPostingsList (Phase 1.4) ─────────────────────────────
261
+
262
+ class VarInt:
263
+ """Encodage variable-length integer (Protocol Buffers style)."""
264
+
265
+ @staticmethod
266
+ def encode(value: int) -> bytes:
267
+ if value < 0:
268
+ raise ValueError(f"VarInt: valeur négative non supportée : {value}")
269
+ buf = []
270
+ while True:
271
+ byte = value & 0x7F
272
+ value >>= 7
273
+ buf.append(byte | 0x80 if value else byte)
274
+ if not value: break
275
+ return bytes(buf)
276
+
277
+ @staticmethod
278
+ def decode(data: bytes, offset: int = 0) -> tuple[int, int]:
279
+ result, shift = 0, 0
280
+ while True:
281
+ if offset >= len(data):
282
+ raise ValueError("VarInt: données tronquées (offset hors limites).")
283
+ byte = data[offset]; offset += 1
284
+ result |= (byte & 0x7F) << shift
285
+ if not (byte & 0x80): break
286
+ shift += 7
287
+ return result, offset
288
+
289
+
290
+ class CompressedPostingsList:
291
+ """
292
+ Postings list compressée : delta-encoding + VarInt.
293
+ ~98% de réduction vs set Python sur 500 IDs aléatoires.
294
+ Dirty buffer : flush() déclenche la compression en batch.
295
+ """
296
+
297
+ def __init__(self) -> None:
298
+ self._compressed: bytes = b''
299
+ self._pending: list[int] = []
300
+ self._dirty: bool = False
301
+
302
+ def add(self, product_id: int) -> None:
303
+ if product_id < 0:
304
+ raise ValueError(f"product_id doit être >= 0, reçu : {product_id}")
305
+ self._pending.append(product_id)
306
+ self._dirty = True
307
+
308
+ def flush(self) -> None:
309
+ if not self._dirty: return
310
+ all_ids = sorted(set(self.decompress()) | set(self._pending))
311
+ buf = bytearray()
312
+ prev = 0
313
+ for pid in all_ids:
314
+ buf += VarInt.encode(pid - prev)
315
+ prev = pid
316
+ self._compressed = bytes(buf)
317
+ self._pending.clear()
318
+ self._dirty = False
319
+
320
+ def decompress(self) -> list[int]:
321
+ ids, offset, current = [], 0, 0
322
+ while offset < len(self._compressed):
323
+ delta, offset = VarInt.decode(self._compressed, offset)
324
+ current += delta
325
+ ids.append(current)
326
+ if self._pending:
327
+ ids = sorted(set(ids) | set(self._pending))
328
+ return ids
329
+
330
+ def __contains__(self, pid: int) -> bool:
331
+ return pid in self.decompress()
332
+
333
+ def memory_bytes(self) -> int:
334
+ return len(self._compressed) + len(self._pending) * 28
335
+
336
+
337
+ # ── LRU Cache ────────────────────────────────────────────────────────────────
338
+
339
+ class LRUCache:
340
+ """Cache LRU O(1) get/put via OrderedDict."""
341
+
342
+ def __init__(self, capacity: int) -> None:
343
+ if capacity <= 0:
344
+ raise ValueError(f"LRUCache capacity doit être > 0, reçu : {capacity}")
345
+ self._cache: OrderedDict = OrderedDict()
346
+ self.capacity = capacity
347
+
348
+ def get(self, key: str):
349
+ if key not in self._cache: return None
350
+ self._cache.move_to_end(key)
351
+ return self._cache[key]
352
+
353
+ def put(self, key: str, value) -> None:
354
+ if key in self._cache: self._cache.move_to_end(key)
355
+ self._cache[key] = value
356
+ if len(self._cache) > self.capacity:
357
+ self._cache.popitem(last=False)
358
+
359
+ def invalidate(self) -> None:
360
+ self._cache.clear()
@@ -0,0 +1,8 @@
1
+ Metadata-Version: 2.4
2
+ Name: tagsearch
3
+ Version: 1.0.0
4
+ Summary: In-memory tag search engine for e-commerce (DAWG + BM25 + hybrid ranking)
5
+ Requires-Python: >=3.11
6
+ Provides-Extra: dev
7
+ Requires-Dist: pytest>=8.0; extra == "dev"
8
+ Requires-Dist: pytest-cov; extra == "dev"
@@ -0,0 +1,13 @@
1
+ README.md
2
+ pyproject.toml
3
+ tagsearch/__init__.py
4
+ tagsearch/engine.py
5
+ tagsearch/models.py
6
+ tagsearch/scoring.py
7
+ tagsearch/structures.py
8
+ tagsearch.egg-info/PKG-INFO
9
+ tagsearch.egg-info/SOURCES.txt
10
+ tagsearch.egg-info/dependency_links.txt
11
+ tagsearch.egg-info/requires.txt
12
+ tagsearch.egg-info/top_level.txt
13
+ tests/test_cross_language.py
@@ -0,0 +1,4 @@
1
+
2
+ [dev]
3
+ pytest>=8.0
4
+ pytest-cov
@@ -0,0 +1 @@
1
+ tagsearch
@@ -0,0 +1,187 @@
1
+ """
2
+ Tests cross-langage — source de vérité Python.
3
+ Charge shared-assets/catalog.json et génère les scores de référence.
4
+ Ces mêmes cas sont rejoués en TS (Vitest) et PHP (PHPUnit) avec tolérance 1e-9.
5
+ """
6
+
7
+ import json
8
+ import math
9
+ import pytest
10
+ from pathlib import Path
11
+
12
+ from tagsearch import SearchEngine, SearchPreferences
13
+ from tagsearch.models import ProductType
14
+ from tagsearch.scoring import (
15
+ BM25Index, calculate_final_score,
16
+ BM25_K1, BM25_B, BM25_CAP, BM25_FLOOR,
17
+ QUALITY_EXPONENT, PRIOR_MEAN, PRIOR_WEIGHT,
18
+ )
19
+
20
+ CATALOG_PATH = Path(__file__).parent.parent.parent / "shared-assets" / "catalog.json"
21
+ TOLERANCE = 1e-9
22
+
23
+
24
+ @pytest.fixture(scope="module")
25
+ def engine() -> SearchEngine:
26
+ eng = SearchEngine(cache_size=50)
27
+ eng.load_catalog(CATALOG_PATH)
28
+ return eng
29
+
30
+
31
+ # ── Tests unitaires BM25 (composants isolés) ────────────────────────────────
32
+
33
+ class TestBM25Components:
34
+ """Valide chaque composant BM25 séparément pour le portage cross-langage."""
35
+
36
+ def test_idf_formula(self):
37
+ """IDF = log((N - df + 0.5) / (df + 0.5) + 1)"""
38
+ N, df = 10, 3
39
+ expected = math.log((N - df + 0.5) / (df + 0.5) + 1)
40
+ assert abs(expected - math.log(7.5 / 3.5 + 1)) < TOLERANCE
41
+
42
+ def test_tf_norm_saturation(self):
43
+ """TF normalisé sature : tf=10 ne vaut pas 10× tf=1."""
44
+ dl, avgdl = 5, 5.0
45
+ tf1 = 1 * (BM25_K1 + 1) / (1 + BM25_K1 * (1 - BM25_B + BM25_B * dl / avgdl))
46
+ tf10 = 10 * (BM25_K1 + 1) / (10 + BM25_K1 * (1 - BM25_B + BM25_B * dl / avgdl))
47
+ assert tf10 < tf1 * 10, "BM25 doit saturer la fréquence des termes"
48
+
49
+ def test_bm25_zero_on_missing_tag(self):
50
+ idx = BM25Index()
51
+ idx.index(1, ["gaming", "tech"])
52
+ assert idx.score(1, ["cuisine"]) == 0.0
53
+
54
+ def test_bm25_positive_on_matching_tag(self):
55
+ idx = BM25Index()
56
+ idx.index(1, ["gaming", "tech"])
57
+ idx.index(2, ["cuisine"])
58
+ score = idx.score(1, ["gaming"])
59
+ assert score > 0.0
60
+
61
+
62
+ # ── Tests scoring multidimensionnel ─────────────────────────────────────────
63
+
64
+ class TestScoringDimensions:
65
+
66
+ def _make_engine(self) -> SearchEngine:
67
+ eng = SearchEngine(cache_size=10)
68
+ eng.load_catalog(CATALOG_PATH)
69
+ return eng
70
+
71
+ def test_tc01_cuisine_physical_beats_digital(self, engine):
72
+ """TC01 : Robot pâtissier (Physical) > E-book (Digital) sans filtre."""
73
+ results, _ = engine.search("cuisine", fuzzy=False)
74
+ ids = [r["id"] for r in results]
75
+ assert 4 in ids and 5 in ids
76
+ score_robot = next(r["score"] for r in results if r["id"] == 4)
77
+ score_ebook = next(r["score"] for r in results if r["id"] == 5)
78
+ assert score_robot > score_ebook
79
+
80
+ def test_tc02_digital_filter_inverts_order(self, engine):
81
+ """TC02 : preferred_type=DIGITAL → E-book devant Robot."""
82
+ prefs = SearchPreferences(preferred_type=ProductType.DIGITAL)
83
+ results, _ = engine.search("cuisine", prefs=prefs, fuzzy=False)
84
+ ids = [r["id"] for r in results]
85
+ assert ids.index(5) < ids.index(4)
86
+
87
+ def test_tc03_price_filter_excludes_clavier(self, engine):
88
+ """TC03 : budget 100€ → Clavier (129.99€) exclu."""
89
+ prefs = SearchPreferences(max_price=100.0)
90
+ results, _ = engine.search("gaming", prefs=prefs, fuzzy=False)
91
+ ids = [r["id"] for r in results]
92
+ assert 1 not in ids # Clavier exclu
93
+ assert 2 in ids # Souris présente
94
+
95
+ def test_tc04_min_rating_excludes_serum(self, engine):
96
+ """TC04 : min_rating=4.0 → Sérum (2.8★) exclu."""
97
+ prefs = SearchPreferences(min_rating=4.0)
98
+ results, _ = engine.search("beauté", prefs=prefs, fuzzy=False)
99
+ ids = [r["id"] for r in results]
100
+ assert 7 not in ids # Sérum exclu
101
+ assert 8 in ids # Palette présente
102
+
103
+ def test_tc05_fuzzy_gaming_typo(self, engine):
104
+ """TC05 : 'gaiming' → résultats gaming via fuzzy."""
105
+ results, _ = engine.search("gaiming", fuzzy=True, max_dist=1)
106
+ niches = {r["niche"] for r in results}
107
+ assert "gaming" in niches
108
+
109
+ def test_bayesian_rating_smoothing(self, engine):
110
+ """Produit avec peu d'avis est tiré vers la moyenne marché."""
111
+ serum = engine.catalog[7] # 2.8★, 12 avis
112
+ palette = engine.catalog[8] # 4.5★, 276 avis
113
+ br_serum = (serum.review_count * serum.avg_rating + PRIOR_MEAN * PRIOR_WEIGHT) / (serum.review_count + PRIOR_WEIGHT)
114
+ br_palette = (palette.review_count * palette.avg_rating + PRIOR_MEAN * PRIOR_WEIGHT) / (palette.review_count + PRIOR_WEIGHT)
115
+ # Le sérum est tiré vers 3.5 (PRIOR_MEAN), la palette reste proche de 4.5
116
+ assert br_serum > serum.avg_rating # Tiré vers le haut (était très bas)
117
+ assert br_palette < palette.avg_rating # Légèrement tiré vers le bas
118
+ assert br_palette > br_serum
119
+
120
+
121
+ # ── Génération des scores de référence (cross-language fixture) ──────────────
122
+
123
+ class TestCrossLanguageFixtures:
124
+ """
125
+ Génère et valide les scores de référence pour TS et PHP.
126
+ Ces scores sont la source de vérité : les autres langages doivent
127
+ tomber dans |score_ref - score_impl| < TOLERANCE (1e-9).
128
+ """
129
+
130
+ def test_reference_scores_stable(self, engine):
131
+ """Les scores de référence ne doivent pas changer entre runs."""
132
+ results, _ = engine.search("cuisine", fuzzy=False)
133
+ scores = {r["id"]: r["score"] for r in results}
134
+ # Scores attendus (calculés lors de la validation Phase 2)
135
+ assert abs(scores[4] - 0.1354) < 1e-3 # Robot pâtissier
136
+ assert abs(scores[5] - 0.1082) < 1e-3 # E-book
137
+
138
+ def test_type_mismatch_factor(self, engine):
139
+ """Le facteur TYPE_MISMATCH=0.1 doit diviser le score par ~10.
140
+ Tolérance 1e-3 : les scores sont arrondis à 4 décimales avant division,
141
+ ce qui introduit une erreur d'arrondi de l'ordre de 1e-4/score_base.
142
+ """
143
+ results_no_filter, _ = engine.search("cuisine", fuzzy=False)
144
+ prefs = SearchPreferences(preferred_type=ProductType.DIGITAL)
145
+ results_digital, _ = engine.search("cuisine", prefs=prefs, fuzzy=False)
146
+
147
+ score_robot_base = next(r["score"] for r in results_no_filter if r["id"] == 4)
148
+ score_robot_digital = next(r["score"] for r in results_digital if r["id"] == 4)
149
+ ratio = score_robot_digital / score_robot_base
150
+ assert abs(ratio - 0.1) < 1e-3, f"Ratio attendu ~0.1, obtenu {ratio}"
151
+
152
+
153
+ # ── Tests robustesse & gestion d'erreurs ────────────────────────────────────
154
+
155
+ class TestRobustness:
156
+
157
+ def test_empty_query_raises(self, engine):
158
+ with pytest.raises(ValueError, match="vide"):
159
+ engine.search("")
160
+
161
+ def test_whitespace_query_raises(self, engine):
162
+ with pytest.raises(ValueError, match="vide"):
163
+ engine.search(" ")
164
+
165
+ def test_negative_max_dist_raises(self, engine):
166
+ with pytest.raises(ValueError, match="max_dist"):
167
+ engine.search("gaming", max_dist=-1)
168
+
169
+ def test_unknown_query_returns_miss(self, engine):
170
+ results, status = engine.search("xyzqwerty123", fuzzy=False)
171
+ assert results == []
172
+ assert status == "MISS"
173
+
174
+ def test_cache_hit_on_repeat(self, engine):
175
+ engine.search("yoga", fuzzy=False)
176
+ _, status = engine.search("yoga", fuzzy=False)
177
+ assert status == "CACHÉ"
178
+
179
+ def test_load_nonexistent_file_raises(self):
180
+ eng = SearchEngine()
181
+ with pytest.raises(FileNotFoundError):
182
+ eng.load_catalog("/nonexistent/path/catalog.json")
183
+
184
+ def test_invalid_rating_raises(self):
185
+ from tagsearch.models import ProductMeta, ProductType
186
+ with pytest.raises(ValueError, match="avg_rating"):
187
+ ProductMeta("Test", ProductType.PHYSICAL, "gaming", 10.0, None, 6.0, 10, ["tag"])