ollama+langchain: 5-MOAD scan complete; langchain-0002 MultiQueryRetriever _unique_documents O(D^2), 21x at D=500
Ollama: all 5 MOADs scanned. MOAD-0001 ollama-0001 (pre-existing). MOADs 0002/0003/0004/0005 CLEAN per defects/ollama/CLEAN.md (scanned 2026-03-31). LangChain: all 5 MOADs scanned. New defect langchain-0002 MOAD-0001 CWE-407. - multi_query.py _unique_documents: `doc not in documents[:i]` creates O(D) slice each iteration and performs O(D) linear scan, giving O(D^2) overall. D = Q*k where Q=queries, k=results per query. - Fix: seen set with hashable proxy key (id, page_content, str(metadata items)). O(D) total. - 9/9 unit tests PASS, 21.4x speedup at D=500. MOADs 0002/0003/0004/0005 CLEAN per updated defects/langchain/CLEAN.md. SCAN-TODO.md: mark both targets [x].
This commit is contained in:
parent
0f711c3996
commit
483f1cb5e5
5 changed files with 260 additions and 25 deletions
|
|
@ -45,8 +45,8 @@ Rule: clone, scan, delete clone after. Keep disk under 90%.
|
|||
|
||||
## Priority 5 — AI/ML new wave
|
||||
|
||||
- [ ] Ollama (Go, local LLM runtime)
|
||||
- [ ] LangChain (Python, LLM orchestration)
|
||||
- [x] Ollama (Go, local LLM runtime) — ollama-0001 MOAD-0001 CWE-407 kvcache buildMask slices.Contains O(B*E) Gemma3 multi-image HIGH 250x; MOADs 0002/0003/0004/0005 CLEAN
|
||||
- [x] LangChain (Python, LLM orchestration) — langchain-0001 MOAD-0001 MultiVectorRetriever id dedup O(D^2) (pre-existing); langchain-0002 MOAD-0001 MultiQueryRetriever _unique_documents slice-in-loop O(D^2) 21x at D=500 MEDIUM; MOADs 0002/0003/0004/0005 CLEAN
|
||||
- [ ] Hugging Face Transformers (Python)
|
||||
- [ ] vLLM (Python/C++, LLM serving)
|
||||
- [x] llama.cpp (C++) — llamacpp-0001 MOAD-0001 CWE-407 grammar_advance_stack std::find O(S^2) MEDIUM-HIGH; MOADs 0002/0003/0004/0005 CLEAN
|
||||
|
|
|
|||
|
|
@ -50,12 +50,9 @@ def _unique_documents(documents: Sequence[Document]) -> list[Document]:
|
|||
seen: set[tuple] = set()
|
||||
result = []
|
||||
for doc in documents:
|
||||
# Build a hashable proxy key. doc.metadata values may not be hashable
|
||||
# so we stringify them. This is the same equality used by __eq__.
|
||||
try:
|
||||
meta_key = tuple(sorted(doc.metadata.items()))
|
||||
except TypeError:
|
||||
meta_key = tuple(sorted((k, str(v)) for k, v in doc.metadata.items()))
|
||||
# Always stringify metadata values to keep the key hashable regardless
|
||||
# of value types (list, dict, etc. are all unhashable in Python).
|
||||
meta_key = tuple(sorted((k, str(v)) for k, v in doc.metadata.items()))
|
||||
key = (doc.id, doc.page_content, meta_key)
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
|
|
|
|||
|
|
@ -18,10 +18,9 @@
|
|||
+ seen: set[tuple] = set()
|
||||
+ result: list[Document] = []
|
||||
+ for doc in documents:
|
||||
+ try:
|
||||
+ meta_key: tuple = tuple(sorted(doc.metadata.items()))
|
||||
+ except TypeError:
|
||||
+ meta_key = tuple(sorted((k, str(v)) for k, v in doc.metadata.items()))
|
||||
+ # Build a hashable proxy key from (id, page_content, metadata items).
|
||||
+ # metadata values may be unhashable (e.g. list), so stringify on fallback.
|
||||
+ meta_key: tuple = tuple(sorted((k, str(v)) for k, v in doc.metadata.items()))
|
||||
+ key = (doc.id, doc.page_content, meta_key)
|
||||
+ if key not in seen:
|
||||
+ seen.add(key)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,211 @@
|
|||
"""
|
||||
LangChainMultiQueryUniqueDedupTest.py
|
||||
|
||||
Unit test for langchain-0002: MultiQueryRetriever _unique_documents O(D^2) fix.
|
||||
|
||||
Tests both the defective (O(D^2)) and fixed (O(D)) implementations to confirm:
|
||||
1. Correctness: fixed returns same result as defective.
|
||||
2. Performance: fixed is significantly faster at scale.
|
||||
|
||||
No install required. Stubs Document with a minimal dataclass.
|
||||
"""
|
||||
|
||||
import time
|
||||
import sys
|
||||
from typing import Any, Optional, Sequence
|
||||
|
||||
|
||||
class Document:
|
||||
"""Minimal Document stub matching LangChain's Document interface."""
|
||||
def __init__(self, page_content: str, metadata: dict = None, id: Optional[str] = None):
|
||||
self.page_content = page_content
|
||||
self.metadata = metadata if metadata is not None else {}
|
||||
self.id = id
|
||||
|
||||
def __eq__(self, other):
|
||||
if not isinstance(other, Document):
|
||||
return False
|
||||
return (self.id == other.id and
|
||||
self.page_content == other.page_content and
|
||||
self.metadata == other.metadata)
|
||||
|
||||
def __repr__(self):
|
||||
return f"Document(id={self.id!r}, page_content={self.page_content[:20]!r})"
|
||||
|
||||
|
||||
# ---- Defective implementation (O(D^2)) ----
|
||||
def _unique_documents_defective(documents: Sequence[Document]) -> list[Document]:
|
||||
"""Original O(D^2): slice-in-loop + linear scan."""
|
||||
return [doc for i, doc in enumerate(documents) if doc not in documents[:i]]
|
||||
|
||||
|
||||
# ---- Fixed implementation (O(D)) ----
|
||||
def _unique_documents_fixed(documents: Sequence[Document]) -> list[Document]:
|
||||
"""Fixed O(D): hashable proxy key in a seen set.
|
||||
|
||||
metadata values may be unhashable (e.g. list), so we always stringify values.
|
||||
This keeps the key hashable regardless of metadata value types.
|
||||
"""
|
||||
seen: set = set()
|
||||
result: list = []
|
||||
for doc in documents:
|
||||
meta_key: tuple = tuple(sorted((k, str(v)) for k, v in doc.metadata.items()))
|
||||
key = (doc.id, doc.page_content, meta_key)
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
result.append(doc)
|
||||
return result
|
||||
|
||||
|
||||
# ---- Tests ----
|
||||
|
||||
def test_empty():
|
||||
assert _unique_documents_fixed([]) == []
|
||||
print("PASS test_empty")
|
||||
|
||||
|
||||
def test_no_duplicates():
|
||||
docs = [Document(f"content {i}", {"src": f"src{i}"}) for i in range(5)]
|
||||
result = _unique_documents_fixed(docs)
|
||||
assert len(result) == 5
|
||||
assert result == docs
|
||||
print("PASS test_no_duplicates")
|
||||
|
||||
|
||||
def test_all_duplicates():
|
||||
doc = Document("same content", {"src": "a"})
|
||||
docs = [doc, doc, doc, doc]
|
||||
result = _unique_documents_fixed(docs)
|
||||
assert len(result) == 1
|
||||
assert result[0] == doc
|
||||
print("PASS test_all_duplicates")
|
||||
|
||||
|
||||
def test_preserves_order():
|
||||
"""First occurrence is kept."""
|
||||
docs = [
|
||||
Document("alpha", {"src": "a"}),
|
||||
Document("beta", {"src": "b"}),
|
||||
Document("alpha", {"src": "a"}), # duplicate of first
|
||||
Document("gamma", {"src": "c"}),
|
||||
]
|
||||
result = _unique_documents_fixed(docs)
|
||||
assert len(result) == 3
|
||||
assert result[0].page_content == "alpha"
|
||||
assert result[1].page_content == "beta"
|
||||
assert result[2].page_content == "gamma"
|
||||
print("PASS test_preserves_order")
|
||||
|
||||
|
||||
def test_same_content_different_metadata():
|
||||
"""Same page_content but different metadata -> both kept."""
|
||||
docs = [
|
||||
Document("shared content", {"src": "a"}),
|
||||
Document("shared content", {"src": "b"}),
|
||||
]
|
||||
result = _unique_documents_fixed(docs)
|
||||
assert len(result) == 2
|
||||
print("PASS test_same_content_different_metadata")
|
||||
|
||||
|
||||
def test_with_ids():
|
||||
"""Documents with distinct ids are deduped by id+content+meta."""
|
||||
docs = [
|
||||
Document("content", {"src": "a"}, id="doc-1"),
|
||||
Document("content", {"src": "a"}, id="doc-1"), # exact duplicate
|
||||
Document("content", {"src": "a"}, id="doc-2"), # different id -> kept
|
||||
]
|
||||
result = _unique_documents_fixed(docs)
|
||||
assert len(result) == 2
|
||||
print("PASS test_with_ids")
|
||||
|
||||
|
||||
def test_matches_defective_on_small():
|
||||
"""Fixed produces same result as defective on small inputs."""
|
||||
docs = []
|
||||
for i in range(20):
|
||||
# Every other doc is a duplicate
|
||||
idx = i if i % 2 == 0 else i - 1
|
||||
docs.append(Document(f"content {idx}", {"src": f"src{idx}"}))
|
||||
|
||||
expected = _unique_documents_defective(docs)
|
||||
result = _unique_documents_fixed(docs)
|
||||
assert result == expected, f"Mismatch:\nexpected={expected}\ngot={result}"
|
||||
print("PASS test_matches_defective_on_small")
|
||||
|
||||
|
||||
def test_performance():
|
||||
"""Fixed is at least 10x faster than defective at D=500."""
|
||||
# Q=10 queries, k=50 results each, 30% duplicates
|
||||
import random
|
||||
random.seed(42)
|
||||
unique_count = 350
|
||||
unique_docs = [Document(f"content {i} " + "x" * 200, {"src": f"src{i}", "idx": i})
|
||||
for i in range(unique_count)]
|
||||
# Build D=500 docs with 150 duplicates
|
||||
docs = unique_docs[:]
|
||||
for _ in range(150):
|
||||
docs.append(random.choice(unique_docs))
|
||||
random.shuffle(docs)
|
||||
D = len(docs)
|
||||
|
||||
# Warmup
|
||||
_unique_documents_defective(docs[:50])
|
||||
_unique_documents_fixed(docs[:50])
|
||||
|
||||
t0 = time.perf_counter()
|
||||
expected = _unique_documents_defective(docs)
|
||||
t_defective = time.perf_counter() - t0
|
||||
|
||||
t0 = time.perf_counter()
|
||||
result = _unique_documents_fixed(docs)
|
||||
t_fixed = time.perf_counter() - t0
|
||||
|
||||
# Correctness: same set of unique documents (order may differ slightly due to
|
||||
# key construction, but set equality should hold)
|
||||
assert set(d.page_content for d in result) == set(d.page_content for d in expected)
|
||||
assert len(result) == len(expected)
|
||||
|
||||
speedup = t_defective / t_fixed if t_fixed > 0 else float("inf")
|
||||
print(f"PASS test_performance: D={D}, defective={t_defective*1000:.1f}ms, "
|
||||
f"fixed={t_fixed*1000:.1f}ms, speedup={speedup:.1f}x")
|
||||
|
||||
assert speedup >= 5.0, f"Expected >=5x speedup, got {speedup:.1f}x"
|
||||
|
||||
|
||||
def test_unhashable_metadata_values():
|
||||
"""Docs with unhashable metadata values (e.g. list) are handled via str fallback."""
|
||||
docs = [
|
||||
Document("content", {"tags": ["a", "b"]}),
|
||||
Document("content", {"tags": ["a", "b"]}), # duplicate
|
||||
Document("content", {"tags": ["c", "d"]}), # different -> kept
|
||||
]
|
||||
result = _unique_documents_fixed(docs)
|
||||
assert len(result) == 2
|
||||
print("PASS test_unhashable_metadata_values")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
tests = [
|
||||
test_empty,
|
||||
test_no_duplicates,
|
||||
test_all_duplicates,
|
||||
test_preserves_order,
|
||||
test_same_content_different_metadata,
|
||||
test_with_ids,
|
||||
test_matches_defective_on_small,
|
||||
test_performance,
|
||||
test_unhashable_metadata_values,
|
||||
]
|
||||
failed = 0
|
||||
for t in tests:
|
||||
try:
|
||||
t()
|
||||
except Exception as e:
|
||||
print(f"FAIL {t.__name__}: {e}")
|
||||
failed += 1
|
||||
if failed:
|
||||
print(f"\n{failed}/{len(tests)} FAILED")
|
||||
sys.exit(1)
|
||||
else:
|
||||
print(f"\n{len(tests)}/{len(tests)} PASSED")
|
||||
|
|
@ -1,19 +1,47 @@
|
|||
# LangChain — CWE-407 Scan Result: CLEAN
|
||||
# LangChain — 5-MOAD Scan Result
|
||||
|
||||
**Date:** 2026-03-30
|
||||
**Date:** 2026-04-03
|
||||
**Target:** https://github.com/langchain-ai/langchain (Python)
|
||||
**Scanner:** Agent Blackops CWE-407 sweep
|
||||
**Focus:** chain/agent tool dedup, document loader dedup, vectorstore result dedup, callback handler membership
|
||||
**Scanner:** Agent Blackops 5-MOAD sweep
|
||||
|
||||
## Findings
|
||||
## MOAD-0001 (CWE-407) — 2 defects found
|
||||
|
||||
No CWE-407 defects found. The codebase is primarily orchestration/configuration code, not heavy data processing.
|
||||
**langchain-0001** (pre-existing): `MultiVectorRetriever` id dedup `not in ids` O(D^2). Fixed.
|
||||
|
||||
**Examined patterns:**
|
||||
- **multi_vector.py** line 108-111: `if d.metadata[self.id_key] not in ids` — O(D^2) dedup, but bounded by search k parameter (typically k=4..20). Not a practical defect.
|
||||
- **jsx.py** line 66-68: `if tag not in component_tags` — O(T^2) dedup of JSX component tags, bounded by typical JSX file size (tens of unique tags).
|
||||
- **langchain_core**: Uses `dict` for visited tracking (function_calling.py), sets for membership tests throughout. Clean patterns.
|
||||
- **partners/**: No linear membership patterns in loops.
|
||||
- **text-splitters/**: No scalable dedup patterns.
|
||||
**langchain-0002** (new): `MultiQueryRetriever._unique_documents` slice-in-loop O(D^2). Fixed.
|
||||
See `defects/langchain-0002/`.
|
||||
|
||||
LangChain's architecture is inherently bounded — document counts come from vector search k, tool lists are developer-defined (typically < 20), callback lists are small. No data-proportional linear scans inside loops.
|
||||
**Other patterns examined and cleared:**
|
||||
- `jsx.py:67` — `if tag not in component_tags`: bounded by file's unique JSX tags (small constant)
|
||||
- `html.py:695` — `if tag not in [header[0] for header in headers_to_split_on]`: init-time, bounded by 6 HTML header levels
|
||||
- `format_to_tool_messages` (tools.py:70) — `new not in messages`: agent steps typically bounded (<50), LOW severity, not filed
|
||||
|
||||
## MOAD-0002 (Intertangle) — CLEAN
|
||||
|
||||
`globals.py` has `_llm_cache`, `_verbose`, `_debug` as module-level mutable globals. These are
|
||||
configuration values set once at startup, not request-scoped state. No coupling of independent
|
||||
subsystems through shared mutable god objects. CallbackManager is per-invocation, not shared.
|
||||
`RunnableConfig` is a TypedDict passed explicitly, not a global.
|
||||
|
||||
## MOAD-0003 (Leaked Context) — CLEAN
|
||||
|
||||
`tracers/context.py` uses `ContextVar` correctly: `tracing_v2_callback_var` and `run_collector_var`
|
||||
are module-level `ContextVar` instances (correct pattern for Python async), with proper
|
||||
`token = var.set(cb); try: yield; finally: var.reset(token)` lifecycle in context managers.
|
||||
No `threading.local` misuse. `var_child_runnable_config` in `runnables/config.py` is also
|
||||
a properly scoped `ContextVar`. No request-scoped identity leaked into thread-local storage.
|
||||
|
||||
## MOAD-0004 (CWE-312) — CLEAN
|
||||
|
||||
Scanned all `logger.*` calls for credential patterns. No log calls found that include
|
||||
`password`, `api_key`, `secret`, `token`, or `credential` variable contents verbatim.
|
||||
Credential values are passed via environment variables (not logged). `LANGCHAIN_API_KEY`
|
||||
and similar are consumed by LangSmith client, not logged by LangChain core.
|
||||
|
||||
## MOAD-0005 (Thundering Herd) — CLEAN
|
||||
|
||||
`_cached_empty_embedding` in `openai/embeddings/base.py` is a closure-local variable
|
||||
created fresh per call — not a shared singleton. `_llm_cache` global is a simple
|
||||
assignment (not a double-checked locking pattern). `_enums_for_spec_lock = threading.Lock()`
|
||||
in `runnables/configurable.py` properly guards its critical section. No unsynchronized
|
||||
check-then-set cache patterns found.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue