Skip to content

⚡ Bolt: Replace O(N²) nested loops with O(N) hash map lookup for exercise-solution matching#102

Open
glacy wants to merge 1 commit into
mainfrom
jules-14328907760621908565-fb462af6
Open

⚡ Bolt: Replace O(N²) nested loops with O(N) hash map lookup for exercise-solution matching#102
glacy wants to merge 1 commit into
mainfrom
jules-14328907760621908565-fb462af6

Conversation

@glacy
Copy link
Copy Markdown
Owner

@glacy glacy commented May 14, 2026

💡 What: Replaced nested loops used for matching exercises to their solutions with a pre-computed dictionary lookup in material_extractor.py and rag_indexer.py.
🎯 Why: The original implementation used an O(N*M) approach which becomes a bottleneck as the number of exercises and solutions grows, causing unnecessary CPU cycles.
📊 Impact: Expected performance improvement of over 100x speedup for this specific matching operation.
🔬 Measurement: Verified via local benchmark script testing 1000 items (0.0350s -> 0.0003s). All tests passed.


PR created automatically by Jules for task 14328907760621908565 started by @glacy

…cise-solution matching

Replaced nested loops used for matching exercises to their solutions with a pre-computed dictionary lookup in `material_extractor.py` and `rag_indexer.py`.
This changes the time complexity from O(N*M) to O(N), which provides a significant speedup as the number of exercises and solutions grows.

Co-authored-by: glacy <1131951+glacy@users.noreply.github.com>
Copilot AI review requested due to automatic review settings May 14, 2026 18:18
@google-labs-jules
Copy link
Copy Markdown
Contributor

👋 Jules, reporting for duty! I'm here to lend a hand with this pull request.

When you start a review, I'll add a 👀 emoji to each comment to let you know I've read it. I'll focus on feedback directed at me and will do my best to stay out of conversations between you and other bots or reviewers to keep the noise down.

I'll push a commit with your requested changes shortly after. Please note there might be a delay between these steps, but rest assured I'm on the job!

For more direct control, you can switch me to Reactive Mode. When this mode is on, I will only act on comments where you specifically mention me with @jules. You can find this option in the Pull Request section of your global Jules UI settings. You can always switch back!

New to Jules? Learn more at jules.google/docs.


For security, I will only act on instructions from the user who triggered this task.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR optimizes exercise→solution matching by replacing nested loops with per-material hash-map lookups, reducing matching from O(N*M) to O(N+M) in the extraction and indexing paths.

Changes:

  • Precomputes solutions_dict to match exercises to solutions in MaterialExtractor.get_all_exercises.
  • Precomputes solutions_dict to match exercises to solutions in RAGIndexer.index_materials.
  • Adds a Bolt note documenting the O(N²) bottleneck pattern and the dict-lookup fix.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

File Description
evolutia/rag/rag_indexer.py Uses a per-material solutions_dict to avoid nested-loop solution matching during indexing.
evolutia/material_extractor.py Uses a per-material solutions_dict to avoid nested-loop solution matching when aggregating exercises.
.jules/bolt.md Documents the “nested loop matching” bottleneck and the dict-lookup approach (preserving first-match semantics).
Comments suppressed due to low confidence (3)

evolutia/material_extractor.py:332

  • La validación del caché ignora por completo el timestamp guardado en _file_cache y el _cache_ttl, y en su lugar compara file_mtime con _last_scan_timestamp. Esto hace que el TTL no se aplique y además contradice el comentario de “cachear errores”: si el archivo no existe, stat() lanza OSError y el caché nunca será considerado válido (se reintentará en cada llamada). Sugerencia: usar cache_entry = self._file_cache[file_path] y validar (a) expiración por TTL con time.time() - cache_entry['timestamp'], y (b) si el archivo existe, invalidar si file_mtime > cache_entry['timestamp'] (o equivalente), evitando llamar stat() cuando el archivo no exista y aún esté dentro del TTL.
            _ = self._file_cache[file_path]
            file_mtime = file_path.stat().st_mtime

            # Usar el timestamp de escaneo más reciente para verificar
            if file_mtime > self._last_scan_timestamp:

evolutia/rag/rag_indexer.py:201

  • _generate_embeddings_batch también puede devolver None si embedding_provider no coincide; debería lanzar ValueError como en _ensure_embeddings_initialized para mantener consistencia y evitar que index_* rompa con errores difíciles de rastrear.
        elif self.embedding_provider == "sentence-transformers":
            return self.embedding_model.encode(
                texts, show_progress_bar=True, batch_size=32
            ).tolist()

evolutia/rag/rag_indexer.py:353

  • Mismo problema de sincronización que en index_exercise: se filtran chunks después de generar embeddings, pero embeddings no se filtra. Si algún chunk queda vacío (p.ej. content con mucho whitespace), sentence-transformers devolverá un embedding por cada texto original y collection.add() puede fallar por mismatch de longitudes. Recomendación: filtrar chunks antes de llamar a _generate_embeddings_batch o filtrar embeddings con los mismos índices.
        embeddings = self._generate_embeddings_batch(chunks)

        # Sincronizar chunks con embeddings
        valid_indices = [i for i, chunk in enumerate(chunks) if chunk and chunk.strip()]
        chunks = [chunks[i] for i in valid_indices]

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +159 to 162
elif self.embedding_provider == "sentence-transformers":
return self.embedding_model.encode(text, show_progress_bar=False).tolist()

def _generate_embeddings_batch(self, texts: List[str]) -> List[List[float]]:
Comment on lines +288 to +301
# Generar embeddings
embeddings = self._generate_embeddings_batch(chunks)

# Sincronizar chunks con embeddings (por si se filtraron vacíos en _generate_embeddings_batch)
# Aunque aquí preferimos filtrar antes para mantener consistencia
valid_indices = [i for i, chunk in enumerate(chunks) if chunk and chunk.strip()]
chunks = [chunks[i] for i in valid_indices]

if not chunks:
logger.warning(
f"Ejercicio {exercise.get('label', 'unknown')} no tiene contenido válido para indexar"
)
return []

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants