codepulse-engine 0.1.0__py3-none-any.whl

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.
Files changed (56) hide show
  1. codepulse/__init__.py +5 -0
  2. codepulse/api/app.py +1336 -0
  3. codepulse/api/errors.py +172 -0
  4. codepulse/api/security.py +189 -0
  5. codepulse/auth.py +406 -0
  6. codepulse/cli.py +1052 -0
  7. codepulse/config.py +475 -0
  8. codepulse/data_location.py +78 -0
  9. codepulse/embeddings/chunker.py +138 -0
  10. codepulse/embeddings/embedder.py +120 -0
  11. codepulse/git_diff.py +201 -0
  12. codepulse/indexer/engine.py +2655 -0
  13. codepulse/indexer/ignore.py +120 -0
  14. codepulse/ingestion_safety.py +169 -0
  15. codepulse/jobs.py +257 -0
  16. codepulse/mcp_server.py +269 -0
  17. codepulse/models.py +140 -0
  18. codepulse/observability.py +130 -0
  19. codepulse/parser/__init__.py +8 -0
  20. codepulse/parser/base.py +58 -0
  21. codepulse/parser/factory.py +37 -0
  22. codepulse/parser/graph.py +502 -0
  23. codepulse/parser/javascript.py +428 -0
  24. codepulse/parser/module_graph.py +232 -0
  25. codepulse/parser/python.py +331 -0
  26. codepulse/rate_limit.py +142 -0
  27. codepulse/repository_identity.py +83 -0
  28. codepulse/search/bm25.py +342 -0
  29. codepulse/search/ranker.py +382 -0
  30. codepulse/search/tokenizer.py +80 -0
  31. codepulse/search/vector.py +357 -0
  32. codepulse/storage/db.py +526 -0
  33. codepulse/storage/migrations.py +347 -0
  34. codepulse/storage/schema_migrations/README +1 -0
  35. codepulse/storage/schema_migrations/env.py +70 -0
  36. codepulse/storage/schema_migrations/script.py.mako +28 -0
  37. codepulse/storage/schema_migrations/versions/135a7b064ea7_add_base_classes_and_relationship_type.py +82 -0
  38. codepulse/storage/schema_migrations/versions/20fc2d0607e2_add_file_content_hash_and_natural_key_.py +106 -0
  39. codepulse/storage/schema_migrations/versions/21a7cb7f1a85_initial_schema.py +105 -0
  40. codepulse/storage/schema_migrations/versions/2cd38eb12a7e_add_call_edge_repo_id_isolation_fix.py +112 -0
  41. codepulse/storage/schema_migrations/versions/3ba396e9c78f_add_symbol_natural_key_uniqueness.py +80 -0
  42. codepulse/storage/schema_migrations/versions/49572f834366_add_embedding_persistence_fields.py +102 -0
  43. codepulse/storage/schema_migrations/versions/7c3e5a91d2f4_add_module_edges.py +61 -0
  44. codepulse/storage/schema_migrations/versions/a41f0d2c6b8e_canonicalize_repository_urls.py +94 -0
  45. codepulse/storage/schema_migrations/versions/b0bdd63949f4_add_api_key_model.py +71 -0
  46. codepulse/storage/schema_migrations/versions/b9014c049e33_add_api_key_repository_role.py +65 -0
  47. codepulse/storage/schema_migrations/versions/c286d277ea21_add_repository_snapshot_model.py +62 -0
  48. codepulse/storage/schema_migrations/versions/de22df2fc3ce_add_index_job_model.py +59 -0
  49. codepulse/storage/schema_migrations/versions/f6e481353553_rename_dependency_to_call_edge_add_confidence.py +80 -0
  50. codepulse/symbol_identity.py +29 -0
  51. codepulse_engine-0.1.0.dist-info/METADATA +191 -0
  52. codepulse_engine-0.1.0.dist-info/RECORD +56 -0
  53. codepulse_engine-0.1.0.dist-info/WHEEL +5 -0
  54. codepulse_engine-0.1.0.dist-info/entry_points.txt +2 -0
  55. codepulse_engine-0.1.0.dist-info/licenses/LICENSE +21 -0
  56. codepulse_engine-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,382 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Sequence
4
+ from codepulse.models import Symbol
5
+
6
+
7
+ class SearchResultItem:
8
+ """A scored result item returned from the hybrid search engine."""
9
+
10
+ def __init__(
11
+ self,
12
+ doc_id: str,
13
+ symbol_name: str,
14
+ file_path: str,
15
+ symbol_type: str,
16
+ start_line: int,
17
+ end_line: int,
18
+ signature: str = "",
19
+ docstring: str | None = None,
20
+ snippet: str = "",
21
+ language: str = "",
22
+ final_score: float = 0.0,
23
+ keyword_score: float = 0.0,
24
+ vector_score: float = 0.0,
25
+ embedding_distance: float = 1.0,
26
+ graph_score: float = 0.0,
27
+ ) -> None:
28
+ self.doc_id = doc_id
29
+ self.symbol_name = symbol_name
30
+ self.file_path = file_path
31
+ self.symbol_type = symbol_type
32
+ self.start_line = start_line
33
+ self.end_line = end_line
34
+ self.signature = signature
35
+ self.docstring = docstring
36
+ self.snippet = snippet
37
+ self.language = language
38
+ self.final_score = final_score
39
+ self.keyword_score = keyword_score
40
+ self.vector_score = vector_score
41
+ self.embedding_distance = embedding_distance
42
+ self.graph_score = graph_score
43
+
44
+ def to_dict(self) -> dict[str, Any]:
45
+ return {
46
+ "doc_id": self.doc_id,
47
+ "symbol_name": self.symbol_name,
48
+ "file_path": self.file_path,
49
+ "type": self.symbol_type,
50
+ "start_line": self.start_line,
51
+ "end_line": self.end_line,
52
+ "signature": self.signature,
53
+ "docstring": self.docstring,
54
+ "snippet": self.snippet,
55
+ "language": self.language,
56
+ "relevance_score": round(self.final_score, 4),
57
+ "keyword_score": round(self.keyword_score, 4),
58
+ "vector_score": round(self.vector_score, 4),
59
+ "embedding_distance": round(self.embedding_distance, 4),
60
+ "graph_score": round(self.graph_score, 4),
61
+ }
62
+
63
+
64
+ class HybridRanker:
65
+ """
66
+ Merges and ranks candidate results from BM25, Dense Vector Search, and the Call Graph.
67
+
68
+ Supports:
69
+ 1. Weighted Linear Fusion (40% keyword + 40% vector + 20% graph + exact match boost)
70
+ 2. Reciprocal Rank Fusion (RRF)
71
+ """
72
+
73
+ def __init__(
74
+ self,
75
+ weight_keyword: float | None = None,
76
+ weight_vector: float | None = None,
77
+ weight_graph: float | None = None,
78
+ exact_match_boost: float | None = None,
79
+ rrf_k: int | None = None,
80
+ vector_floor_rank: int | None = None,
81
+ ) -> None:
82
+ from codepulse.config import get_settings
83
+ settings = get_settings()
84
+ self.weight_keyword = weight_keyword if weight_keyword is not None else settings.weight_keyword
85
+ self.weight_vector = weight_vector if weight_vector is not None else settings.weight_vector
86
+ self.weight_graph = weight_graph if weight_graph is not None else settings.weight_graph
87
+ self.exact_match_boost = (
88
+ exact_match_boost if exact_match_boost is not None else settings.exact_match_boost
89
+ )
90
+ # Phase 2.3 (roadmap §37 item 2.3: "...selectable via API
91
+ # search_type/config") -- resolved once at construction, matching
92
+ # every other tunable ranking parameter above, rather than read
93
+ # fresh from settings on every fuse_rrf() call.
94
+ self.rrf_k = rrf_k if rrf_k is not None else settings.rrf_k
95
+ self.vector_floor_rank = (
96
+ vector_floor_rank if vector_floor_rank is not None else settings.vector_floor_rank
97
+ )
98
+
99
+ def _vector_scores_for_fusion(self, vector_results: list[dict[str, Any]]) -> dict[str, float]:
100
+ """
101
+ The semantic score each vector candidate contributes to weighted fusion
102
+ (FUTURE_CHANGES item 20, part 2).
103
+
104
+ The lexical score is relative to the query's best keyword hit; a raw
105
+ cosine similarity is absolute and, for real code, sits in a narrow band
106
+ (the old `(cosine + 1) / 2` lived in roughly 0.5..0.9), so weighting the
107
+ two 0.4 / 0.4 compressed the semantic signal. Here the score is
108
+ `(sim - floor) / (top - floor)` clipped to 0..1: `top` is the best
109
+ similarity for this query, `floor` the similarity of the hit at
110
+ `vector_floor_rank`. Hits ranked below the floor therefore never change
111
+ anyone's score, so a result scores the same whatever `limit` was asked
112
+ for, and input order is irrelevant. With no spread (a single candidate,
113
+ or all equal) every candidate with positive similarity scores 1.0
114
+ rather than being zeroed. `vector_floor_rank == 0` is the old absolute
115
+ scaling. The value REPORTED on a result (`vector_score`) is always the
116
+ real, unscaled one; only the fusion input is scaled.
117
+ """
118
+ if self.vector_floor_rank <= 0:
119
+ return {it["doc_id"]: it.get("relevance_score", 0.0) for it in vector_results}
120
+
121
+ def sim_of(it: dict[str, Any]) -> float:
122
+ sim = it.get("similarity")
123
+ if sim is None:
124
+ sim = 2.0 * it.get("relevance_score", 0.5) - 1.0
125
+ return max(0.0, sim)
126
+
127
+ sims = sorted((sim_of(it) for it in vector_results), reverse=True)
128
+ if not sims:
129
+ return {}
130
+ top = sims[0]
131
+ floor = sims[min(self.vector_floor_rank, len(sims)) - 1]
132
+ span = top - floor
133
+ scaled: dict[str, float] = {}
134
+ for it in vector_results:
135
+ sim = sim_of(it)
136
+ if top <= 0.0:
137
+ scaled[it["doc_id"]] = 0.0
138
+ elif span <= 1e-12:
139
+ scaled[it["doc_id"]] = 1.0
140
+ else:
141
+ scaled[it["doc_id"]] = min(1.0, max(0.0, (sim - floor) / span))
142
+ return scaled
143
+
144
+ def _build_result_item(
145
+ self,
146
+ doc_id: str,
147
+ metadata: dict[str, Any],
148
+ *,
149
+ final_score: float,
150
+ keyword_score: float = 0.0,
151
+ vector_score: float = 0.0,
152
+ embedding_distance: float = 1.0,
153
+ graph_score: float = 0.0,
154
+ ) -> SearchResultItem:
155
+ """
156
+ Reconstructs a `SearchResultItem` from a candidate's metadata dict
157
+ (the same shape `BM25Index`/`VectorIndex` attach to every result --
158
+ `symbol_name`, `file_path`, `type`, `start_line`, `end_line`,
159
+ `signature`, `docstring`, `snippet`, `language`) plus whatever
160
+ score fields the calling fusion strategy already computed.
161
+
162
+ Phase 2.3 (roadmap §36/§37 item 2.3: "wire the already-implemented
163
+ (but unused) RRF fusion path to be selectable via the API") --
164
+ shared by both `fuse()` (weighted linear fusion) and `fuse_rrf()`
165
+ (Reciprocal Rank Fusion) so this metadata-to-result-object mapping
166
+ is never duplicated between the two fusion strategies. `fuse()`'s
167
+ own metadata-reconstruction logic is unchanged in behavior; it now
168
+ simply calls this shared helper instead of inlining the same
169
+ field-by-field extraction itself.
170
+ """
171
+ meta = metadata
172
+ return SearchResultItem(
173
+ doc_id=doc_id,
174
+ symbol_name=meta.get("symbol_name", ""),
175
+ file_path=meta.get("file_path", ""),
176
+ symbol_type=meta.get("type", "function"),
177
+ start_line=meta.get("start_line", 1),
178
+ end_line=meta.get("end_line", 1),
179
+ signature=meta.get("signature", ""),
180
+ docstring=meta.get("docstring"),
181
+ snippet=meta.get("snippet", meta.get("signature", "") or meta.get("symbol_name", "")),
182
+ language=meta.get("language", ""),
183
+ final_score=final_score,
184
+ keyword_score=keyword_score,
185
+ vector_score=vector_score,
186
+ embedding_distance=embedding_distance,
187
+ graph_score=graph_score,
188
+ )
189
+
190
+ def rank_by_similarity(self, vector_results: list[dict[str, Any]], top_k: int = 20) -> list[SearchResultItem]:
191
+ """
192
+ Semantic-only search: results ordered purely by vector similarity.
193
+
194
+ Nothing is fused here, so there is no keyword/graph term and no
195
+ exact-name boost, and the score reported is the real relevance of the
196
+ match. (It used to go through `fuse()`: the score became
197
+ `0.4 x relevance + 0.15 boost`, a narrow 0.2-0.36 band in which a
198
+ name that merely appeared inside the query text jumped past much closer
199
+ matches; on a second codebase MRR was 0.272 against 0.551 without the
200
+ boost.) Ties break on `doc_id`, so the order is deterministic.
201
+ """
202
+ ordered = sorted(vector_results, key=lambda it: (-it.get("relevance_score", 0.0), it["doc_id"]))
203
+ return [
204
+ self._build_result_item(
205
+ it["doc_id"],
206
+ it.get("metadata", {}),
207
+ final_score=it.get("relevance_score", 0.0),
208
+ vector_score=it.get("relevance_score", 0.0),
209
+ embedding_distance=it.get("embedding_distance", 1.0),
210
+ )
211
+ for it in ordered[:top_k]
212
+ ]
213
+
214
+ def fuse(
215
+ self,
216
+ query: str,
217
+ bm25_results: list[dict[str, Any]],
218
+ vector_results: list[dict[str, Any]],
219
+ graph_scores: dict[str, float] | None = None, # doc_id -> score in [0, 1]
220
+ top_k: int = 20,
221
+ ) -> list[SearchResultItem]:
222
+ """
223
+ Fuses BM25 and Vector search results using weighted linear combination.
224
+ The semantic score is scaled relative to the query's own results before
225
+ it is weighted against the lexical score (`_vector_scores_for_fusion`).
226
+ """
227
+ graph_scores = graph_scores or {}
228
+ query_lower = query.lower().strip()
229
+ fusion_vec_scores = self._vector_scores_for_fusion(vector_results)
230
+
231
+ # Collect all unique candidate doc_ids
232
+ candidates: dict[str, dict[str, Any]] = {}
233
+
234
+ # 1. Process BM25 matches
235
+ for item in bm25_results:
236
+ doc_id = item["doc_id"]
237
+ candidates[doc_id] = {
238
+ "metadata": item.get("metadata", {}),
239
+ "kw_score": item.get("normalized_score", 0.0),
240
+ "vec_score": 0.0,
241
+ "emb_dist": 1.0,
242
+ "graph_score": graph_scores.get(doc_id, 0.0),
243
+ }
244
+
245
+ # 2. Process Vector matches
246
+ for item in vector_results:
247
+ doc_id = item["doc_id"]
248
+ if doc_id not in candidates:
249
+ candidates[doc_id] = {
250
+ "metadata": item.get("metadata", {}),
251
+ "kw_score": 0.0,
252
+ "vec_score": item.get("relevance_score", 0.0),
253
+ "emb_dist": item.get("embedding_distance", 1.0),
254
+ "graph_score": graph_scores.get(doc_id, 0.0),
255
+ }
256
+ else:
257
+ candidates[doc_id]["vec_score"] = item.get("relevance_score", 0.0)
258
+ candidates[doc_id]["emb_dist"] = item.get("embedding_distance", 1.0)
259
+ if not candidates[doc_id]["metadata"] and item.get("metadata"):
260
+ candidates[doc_id]["metadata"] = item["metadata"]
261
+
262
+ scored_items: list[SearchResultItem] = []
263
+
264
+ for doc_id, data in candidates.items():
265
+ meta = data["metadata"]
266
+ sym_name = meta.get("symbol_name", "")
267
+
268
+ kw_score = data["kw_score"]
269
+ vec_score = data["vec_score"]
270
+ emb_dist = data["emb_dist"]
271
+ g_score = data["graph_score"]
272
+
273
+ # Compute weighted combination
274
+ combined = (
275
+ self.weight_keyword * kw_score
276
+ + self.weight_vector * fusion_vec_scores.get(doc_id, 0.0)
277
+ + self.weight_graph * g_score
278
+ )
279
+
280
+ # Apply exact match boost if the query explicitly names the symbol
281
+ if sym_name and sym_name.lower() in query_lower:
282
+ combined += self.exact_match_boost
283
+ elif query_lower in sym_name.lower():
284
+ combined += self.exact_match_boost * 0.5
285
+
286
+ final_score = min(1.0, combined)
287
+
288
+ scored_items.append(
289
+ self._build_result_item(
290
+ doc_id,
291
+ meta,
292
+ final_score=final_score,
293
+ keyword_score=kw_score,
294
+ vector_score=vec_score,
295
+ embedding_distance=emb_dist,
296
+ graph_score=g_score,
297
+ )
298
+ )
299
+
300
+ # Sort descending by final score
301
+ scored_items.sort(key=lambda x: x.final_score, reverse=True)
302
+ return scored_items[:top_k]
303
+
304
+ def fuse_rrf(
305
+ self,
306
+ bm25_results: list[dict[str, Any]],
307
+ vector_results: list[dict[str, Any]],
308
+ k: int | None = None,
309
+ top_k: int = 20,
310
+ ) -> list[SearchResultItem]:
311
+ """
312
+ Reciprocal Rank Fusion (RRF) combiner.
313
+ Formula: RRF_score(d) = sum(1 / (k + rank_i(d)))
314
+
315
+ `k` defaults to `self.rrf_k` (itself resolved from
316
+ `settings.rrf_k`, default 60 -- the conventional constant from the
317
+ original RRF paper) when not explicitly overridden by the caller.
318
+
319
+ Phase 2.3 (roadmap §36/§37 item 2.3): returns `list[SearchResultItem]`
320
+ -- the same return type `fuse()` already produces, and the shape
321
+ `IndexingEngine.search()`/`/v1/search`'s `[r.to_dict() for r in
322
+ results]` already expect -- rather than the raw
323
+ `{"doc_id", "rrf_score", "metadata"}` dicts this method returned
324
+ before this milestone wired it up to the API. The fusion algorithm
325
+ itself (the RRF formula/ranking below) is unchanged; only the
326
+ final step -- turning a ranked (doc_id, score) list into full
327
+ result objects -- was added, reusing `_build_result_item` so this
328
+ mapping is never duplicated against `fuse()`'s own.
329
+
330
+ `final_score`/`relevance_score` on the returned items is the real,
331
+ computed RRF score (unbounded above 0, not rescaled/clamped to
332
+ [0, 1] the way `fuse()`'s weighted-linear score is) -- reporting
333
+ the genuine computed value rather than inventing a normalization
334
+ the roadmap does not ask for (CLAUDE.md: no fabricated metrics).
335
+ `keyword_score`/`vector_score` are still populated from each
336
+ candidate's own real, already-computed per-source score (the same
337
+ values `fuse()` reads), even though RRF itself combines by rank,
338
+ not by these scores -- for transparency, not because RRF's own
339
+ formula uses them. `graph_score` is always 0.0: `fuse_rrf` does
340
+ not accept a `graph_scores` argument (unchanged from before this
341
+ milestone -- adding call-graph fusion to RRF would be a ranking
342
+ algorithm change, out of this milestone's scope, which only wires
343
+ up the already-implemented, unmodified RRF formula).
344
+ """
345
+ effective_k = k if k is not None else self.rrf_k
346
+
347
+ rrf_scores: dict[str, float] = {}
348
+ metadata_map: dict[str, dict[str, Any]] = {}
349
+ kw_score_map: dict[str, float] = {}
350
+ vec_score_map: dict[str, float] = {}
351
+ emb_dist_map: dict[str, float] = {}
352
+
353
+ for rank, item in enumerate(bm25_results, start=1):
354
+ doc_id = item["doc_id"]
355
+ rrf_scores[doc_id] = rrf_scores.get(doc_id, 0.0) + (1.0 / (effective_k + rank))
356
+ metadata_map[doc_id] = item.get("metadata", {})
357
+ kw_score_map[doc_id] = item.get("normalized_score", 0.0)
358
+
359
+ for rank, item in enumerate(vector_results, start=1):
360
+ doc_id = item["doc_id"]
361
+ rrf_scores[doc_id] = rrf_scores.get(doc_id, 0.0) + (1.0 / (effective_k + rank))
362
+ if doc_id not in metadata_map:
363
+ metadata_map[doc_id] = item.get("metadata", {})
364
+ vec_score_map[doc_id] = item.get("relevance_score", 0.0)
365
+ emb_dist_map[doc_id] = item.get("embedding_distance", 1.0)
366
+
367
+ sorted_docs = sorted(rrf_scores.items(), key=lambda x: x[1], reverse=True)
368
+
369
+ results: list[SearchResultItem] = []
370
+ for doc_id, score in sorted_docs[:top_k]:
371
+ results.append(
372
+ self._build_result_item(
373
+ doc_id,
374
+ metadata_map.get(doc_id, {}),
375
+ final_score=score,
376
+ keyword_score=kw_score_map.get(doc_id, 0.0),
377
+ vector_score=vec_score_map.get(doc_id, 0.0),
378
+ embedding_distance=emb_dist_map.get(doc_id, 1.0),
379
+ graph_score=0.0,
380
+ )
381
+ )
382
+ return results
@@ -0,0 +1,80 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from typing import Sequence
5
+
6
+ # Regex to split camelCase / PascalCase and snake_case tokens
7
+ _CAMEL_SPLIT_REGEX = re.compile(r"([a-z0-9])([A-Z])|([A-Z]+)([A-Z][a-z])")
8
+ _NON_ALPHANUM_REGEX = re.compile(r"[^a-zA-Z0-9_]")
9
+
10
+
11
+ def split_identifier(identifier: str) -> list[str]:
12
+ """
13
+ Splits a programming identifier into its constituent words.
14
+
15
+ Examples:
16
+ 'createDbConnection' -> ['create', 'Db', 'Connection']
17
+ 'POSTGRES_POOL_SIZE' -> ['POSTGRES', 'POOL', 'SIZE']
18
+ 'ASTVisitorV2' -> ['AST', 'Visitor', 'V2']
19
+ 'get_user_by_id' -> ['get', 'user', 'by', 'id']
20
+ """
21
+ if not identifier:
22
+ return []
23
+
24
+ # First split by underscores
25
+ parts = identifier.split("_")
26
+ sub_words: list[str] = []
27
+
28
+ for part in parts:
29
+ if not part:
30
+ continue
31
+ # Split camelCase / PascalCase
32
+ # e.g., 'createDbConnection' -> 'create Db Connection'
33
+ s = _CAMEL_SPLIT_REGEX.sub(r"\1\3 \2\4", part)
34
+ for w in s.split():
35
+ if w:
36
+ sub_words.append(w)
37
+
38
+ return sub_words
39
+
40
+
41
+ def tokenize_code(text: str, preserve_case: bool = False) -> list[str]:
42
+ """
43
+ Code-aware tokenizer for BM25 and lexical search.
44
+
45
+ Produces both:
46
+ 1. Full raw tokens (e.g., 'createDbConnection')
47
+ 2. Split sub-tokens (e.g., 'create', 'db', 'connection')
48
+
49
+ This ensures that searches for both exact identifiers ('createDbConnection')
50
+ and sub-components ('connection') match with high relevance.
51
+ """
52
+ if not text:
53
+ return []
54
+
55
+ # Replace non-alphanumeric (except underscores) with spaces
56
+ cleaned = _NON_ALPHANUM_REGEX.sub(" ", text)
57
+ raw_tokens = cleaned.split()
58
+
59
+ tokens: list[str] = []
60
+ seen: set[str] = set()
61
+
62
+ for raw in raw_tokens:
63
+ # Filter out purely numeric or single-character noisy tokens
64
+ if len(raw) == 1 and not raw.isalpha():
65
+ continue
66
+
67
+ base = raw if preserve_case else raw.lower()
68
+ if base not in seen:
69
+ tokens.append(base)
70
+ seen.add(base)
71
+
72
+ # Generate sub-tokens
73
+ sub_tokens = split_identifier(raw)
74
+ for st in sub_tokens:
75
+ sub = st if preserve_case else st.lower()
76
+ if sub and len(sub) > 1 and sub not in seen:
77
+ tokens.append(sub)
78
+ seen.add(sub)
79
+
80
+ return tokens