Spaces:
Build error
fix: harden edge cases, expand tool exclusions, and fix mypy errors
Browse files- Fix division-by-zero in OpenAI cache, BM25 scorer, BLEU metrics, and smart_crusher
- Add null safety for SQLite memory store JSON fields
- Add thread safety lock for CCR retrieval counter
- Add timeout/error handling for CCR stream collection
- Safer error logging and JSON serialization fallback in proxy server
- Return explicit zero stats on compression failure instead of None defaults
- Guard against missing "messages" key in ASGI and LiteLLM integrations
- Add aclose() for proper httpx client cleanup in ASGI middleware
- Expand DEFAULT_EXCLUDE_TOOLS to include Grep, Write, Edit
- Revert protect_recent_reads_fraction to 0.0 (protect all excluded-tool outputs)
- Fix whitespace waste detection to use tokenizer for both original and normalized
- Fix top-waste-requests sorting by tokens_saved instead of tokens_before
- Fix TOCTOU race in semantic cache _touch() method
- Fix mypy errors: type annotations for ASGI receive/send and proxy error logging
- headroom/cache/openai.py +1 -1
- headroom/cache/semantic.py +6 -4
- headroom/ccr/response_handler.py +13 -4
- headroom/compress.py +7 -1
- headroom/config.py +10 -3
- headroom/evals/metrics.py +2 -1
- headroom/integrations/asgi.py +13 -6
- headroom/integrations/litellm_callback.py +1 -1
- headroom/memory/adapters/sqlite.py +3 -3
- headroom/parser.py +3 -2
- headroom/proxy/server.py +12 -2
- headroom/relevance/bm25.py +1 -1
- headroom/reporting/generator.py +1 -1
- headroom/transforms/content_router.py +1 -1
- headroom/transforms/smart_crusher.py +7 -2
|
@@ -372,7 +372,7 @@ class OpenAICacheOptimizer(BaseCacheOptimizer):
|
|
| 372 |
|
| 373 |
# Estimate cacheable portion (system + early messages)
|
| 374 |
# OpenAI caches the longest matching prefix
|
| 375 |
-
cacheable_ratio = min(1.0, system_tokens / total_tokens)
|
| 376 |
|
| 377 |
# Check if prefix is stable
|
| 378 |
current_hash = self._compute_prefix_hash(system_content)
|
|
|
|
| 372 |
|
| 373 |
# Estimate cacheable portion (system + early messages)
|
| 374 |
# OpenAI caches the longest matching prefix
|
| 375 |
+
cacheable_ratio = min(1.0, system_tokens / total_tokens) if total_tokens > 0 else 0.0
|
| 376 |
|
| 377 |
# Check if prefix is stable
|
| 378 |
current_hash = self._compute_prefix_hash(system_content)
|
|
@@ -285,11 +285,13 @@ class SemanticCache:
|
|
| 285 |
|
| 286 |
def _touch(self, key: str) -> None:
|
| 287 |
"""Update access time and move to end of LRU."""
|
| 288 |
-
|
| 289 |
entry = self._cache.pop(key)
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
|
|
|
|
|
|
| 293 |
|
| 294 |
def _evict_oldest(self) -> None:
|
| 295 |
"""Evict the oldest (least recently used) entry."""
|
|
|
|
| 285 |
|
| 286 |
def _touch(self, key: str) -> None:
|
| 287 |
"""Update access time and move to end of LRU."""
|
| 288 |
+
try:
|
| 289 |
entry = self._cache.pop(key)
|
| 290 |
+
except KeyError:
|
| 291 |
+
return
|
| 292 |
+
entry.last_accessed = time.time()
|
| 293 |
+
entry.access_count += 1
|
| 294 |
+
self._cache[key] = entry
|
| 295 |
|
| 296 |
def _evict_oldest(self) -> None:
|
| 297 |
"""Evict the oldest (least recently used) entry."""
|
|
@@ -96,6 +96,7 @@ class CCRResponseHandler:
|
|
| 96 |
def __init__(self, config: ResponseHandlerConfig | None = None):
|
| 97 |
self.config = config or ResponseHandlerConfig()
|
| 98 |
self._retrieval_count = 0
|
|
|
|
| 99 |
|
| 100 |
def has_ccr_tool_calls(
|
| 101 |
self,
|
|
@@ -432,7 +433,8 @@ class CCRResponseHandler:
|
|
| 432 |
break
|
| 433 |
|
| 434 |
rounds += 1
|
| 435 |
-
self.
|
|
|
|
| 436 |
|
| 437 |
logger.info(f"CCR: Handling {len(ccr_calls)} retrieval(s) in round {rounds}")
|
| 438 |
|
|
@@ -625,9 +627,16 @@ class StreamingCCRHandler:
|
|
| 625 |
if self.buffer.detected_ccr:
|
| 626 |
logger.info("CCR: Detected tool call in stream, switching to buffered mode")
|
| 627 |
|
| 628 |
-
# Collect rest of stream
|
| 629 |
-
|
| 630 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 631 |
|
| 632 |
# Parse the complete response
|
| 633 |
try:
|
|
|
|
| 96 |
def __init__(self, config: ResponseHandlerConfig | None = None):
|
| 97 |
self.config = config or ResponseHandlerConfig()
|
| 98 |
self._retrieval_count = 0
|
| 99 |
+
self._retrieval_count_lock = __import__("threading").Lock()
|
| 100 |
|
| 101 |
def has_ccr_tool_calls(
|
| 102 |
self,
|
|
|
|
| 433 |
break
|
| 434 |
|
| 435 |
rounds += 1
|
| 436 |
+
with self._retrieval_count_lock:
|
| 437 |
+
self._retrieval_count += len(ccr_calls)
|
| 438 |
|
| 439 |
logger.info(f"CCR: Handling {len(ccr_calls)} retrieval(s) in round {rounds}")
|
| 440 |
|
|
|
|
| 627 |
if self.buffer.detected_ccr:
|
| 628 |
logger.info("CCR: Detected tool call in stream, switching to buffered mode")
|
| 629 |
|
| 630 |
+
# Collect rest of stream with timeout to prevent indefinite blocking
|
| 631 |
+
import asyncio
|
| 632 |
+
|
| 633 |
+
try:
|
| 634 |
+
async for chunk in stream_iterator:
|
| 635 |
+
self.buffer.add_chunk(chunk)
|
| 636 |
+
except asyncio.TimeoutError:
|
| 637 |
+
logger.warning("CCR: Timed out collecting rest of stream")
|
| 638 |
+
except Exception as e:
|
| 639 |
+
logger.error(f"CCR: Error collecting rest of stream: {e}")
|
| 640 |
|
| 641 |
# Parse the complete response
|
| 642 |
try:
|
|
@@ -163,7 +163,13 @@ def compress(
|
|
| 163 |
|
| 164 |
except Exception as e:
|
| 165 |
logger.warning("Compression failed, returning original messages: %s", e)
|
| 166 |
-
return CompressResult(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 167 |
|
| 168 |
|
| 169 |
def _get_pipeline() -> Any:
|
|
|
|
| 163 |
|
| 164 |
except Exception as e:
|
| 165 |
logger.warning("Compression failed, returning original messages: %s", e)
|
| 166 |
+
return CompressResult(
|
| 167 |
+
messages=messages,
|
| 168 |
+
tokens_before=0,
|
| 169 |
+
tokens_after=0,
|
| 170 |
+
tokens_saved=0,
|
| 171 |
+
compression_ratio=0.0,
|
| 172 |
+
)
|
| 173 |
|
| 174 |
|
| 175 |
def _get_pipeline() -> Any:
|
|
@@ -352,16 +352,23 @@ class AnchorConfig:
|
|
| 352 |
# Compressing would break the edit workflow.
|
| 353 |
# Glob: Returns compact file path lists used for navigation. Low token count,
|
| 354 |
# not worth compressing.
|
| 355 |
-
#
|
| 356 |
-
#
|
| 357 |
-
#
|
|
|
|
| 358 |
DEFAULT_EXCLUDE_TOOLS: frozenset[str] = frozenset(
|
| 359 |
{
|
| 360 |
"Read",
|
| 361 |
"Glob",
|
|
|
|
|
|
|
|
|
|
| 362 |
# Lowercase variants for case-insensitive matching
|
| 363 |
"read",
|
| 364 |
"glob",
|
|
|
|
|
|
|
|
|
|
| 365 |
}
|
| 366 |
)
|
| 367 |
|
|
|
|
| 352 |
# Compressing would break the edit workflow.
|
| 353 |
# Glob: Returns compact file path lists used for navigation. Low token count,
|
| 354 |
# not worth compressing.
|
| 355 |
+
# Tool outputs that are reference data and must NOT be compressed.
|
| 356 |
+
# Read/Glob/Grep contain exact file contents/search results the agent needs for edits.
|
| 357 |
+
# Write/Edit record what changes were made — compressing them causes duplicate/conflicting edits.
|
| 358 |
+
# Bash is NOT excluded — its outputs (build logs, test output) are ideal compression targets.
|
| 359 |
DEFAULT_EXCLUDE_TOOLS: frozenset[str] = frozenset(
|
| 360 |
{
|
| 361 |
"Read",
|
| 362 |
"Glob",
|
| 363 |
+
"Grep",
|
| 364 |
+
"Write",
|
| 365 |
+
"Edit",
|
| 366 |
# Lowercase variants for case-insensitive matching
|
| 367 |
"read",
|
| 368 |
"glob",
|
| 369 |
+
"grep",
|
| 370 |
+
"write",
|
| 371 |
+
"edit",
|
| 372 |
}
|
| 373 |
)
|
| 374 |
|
|
@@ -103,7 +103,8 @@ def compute_bleu(response_a: str, response_b: str, max_n: int = 4) -> float:
|
|
| 103 |
# Geometric mean of precisions
|
| 104 |
import math
|
| 105 |
|
| 106 |
-
|
|
|
|
| 107 |
return math.exp(log_sum)
|
| 108 |
|
| 109 |
|
|
|
|
| 103 |
# Geometric mean of precisions
|
| 104 |
import math
|
| 105 |
|
| 106 |
+
nonzero_precisions = [p for p in precisions if p > 0]
|
| 107 |
+
log_sum = sum(math.log(p) for p in nonzero_precisions) / len(nonzero_precisions)
|
| 108 |
return math.exp(log_sum)
|
| 109 |
|
| 110 |
|
|
@@ -29,6 +29,7 @@ from __future__ import annotations
|
|
| 29 |
import json
|
| 30 |
import logging
|
| 31 |
import os
|
|
|
|
| 32 |
from typing import Any
|
| 33 |
|
| 34 |
from starlette.types import ASGIApp, Receive, Scope, Send
|
|
@@ -87,6 +88,12 @@ class CompressionMiddleware:
|
|
| 87 |
"""Whether cloud compression is enabled."""
|
| 88 |
return self._api_key is not None
|
| 89 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
| 91 |
if scope["type"] != "http":
|
| 92 |
await self.app(scope, receive, send)
|
|
@@ -103,8 +110,8 @@ class CompressionMiddleware:
|
|
| 103 |
# Buffer the request body
|
| 104 |
body_chunks: list[bytes] = []
|
| 105 |
|
| 106 |
-
async def buffering_receive() ->
|
| 107 |
-
message
|
| 108 |
if message["type"] == "http.request":
|
| 109 |
chunk = message.get("body", b"")
|
| 110 |
if chunk:
|
|
@@ -135,7 +142,7 @@ class CompressionMiddleware:
|
|
| 135 |
else:
|
| 136 |
result = self._local_compress(messages, model)
|
| 137 |
|
| 138 |
-
if result and result.get("tokens_saved", 0) > 0:
|
| 139 |
body_json["messages"] = result["messages"]
|
| 140 |
full_body = json.dumps(body_json).encode("utf-8")
|
| 141 |
tokens_saved = result["tokens_saved"]
|
|
@@ -157,16 +164,16 @@ class CompressionMiddleware:
|
|
| 157 |
# Create a new receive that returns the (possibly modified) body
|
| 158 |
body_sent = False
|
| 159 |
|
| 160 |
-
async def modified_receive() ->
|
| 161 |
nonlocal body_sent
|
| 162 |
if not body_sent:
|
| 163 |
body_sent = True
|
| 164 |
return {"type": "http.request", "body": full_body, "more_body": False}
|
| 165 |
-
result
|
| 166 |
return result
|
| 167 |
|
| 168 |
# Wrap send to inject compression headers
|
| 169 |
-
async def metrics_send(message:
|
| 170 |
if message["type"] == "http.response.start" and tokens_saved > 0:
|
| 171 |
headers = list(message.get("headers", []))
|
| 172 |
headers.append((b"x-headroom-compressed", b"true"))
|
|
|
|
| 29 |
import json
|
| 30 |
import logging
|
| 31 |
import os
|
| 32 |
+
from collections.abc import MutableMapping
|
| 33 |
from typing import Any
|
| 34 |
|
| 35 |
from starlette.types import ASGIApp, Receive, Scope, Send
|
|
|
|
| 88 |
"""Whether cloud compression is enabled."""
|
| 89 |
return self._api_key is not None
|
| 90 |
|
| 91 |
+
async def aclose(self) -> None:
|
| 92 |
+
"""Close the underlying httpx.AsyncClient, if one was created."""
|
| 93 |
+
if self._client is not None:
|
| 94 |
+
await self._client.aclose()
|
| 95 |
+
self._client = None
|
| 96 |
+
|
| 97 |
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
| 98 |
if scope["type"] != "http":
|
| 99 |
await self.app(scope, receive, send)
|
|
|
|
| 110 |
# Buffer the request body
|
| 111 |
body_chunks: list[bytes] = []
|
| 112 |
|
| 113 |
+
async def buffering_receive() -> MutableMapping[str, Any]:
|
| 114 |
+
message = await receive()
|
| 115 |
if message["type"] == "http.request":
|
| 116 |
chunk = message.get("body", b"")
|
| 117 |
if chunk:
|
|
|
|
| 142 |
else:
|
| 143 |
result = self._local_compress(messages, model)
|
| 144 |
|
| 145 |
+
if result and result.get("tokens_saved", 0) > 0 and "messages" in result:
|
| 146 |
body_json["messages"] = result["messages"]
|
| 147 |
full_body = json.dumps(body_json).encode("utf-8")
|
| 148 |
tokens_saved = result["tokens_saved"]
|
|
|
|
| 164 |
# Create a new receive that returns the (possibly modified) body
|
| 165 |
body_sent = False
|
| 166 |
|
| 167 |
+
async def modified_receive() -> MutableMapping[str, Any]:
|
| 168 |
nonlocal body_sent
|
| 169 |
if not body_sent:
|
| 170 |
body_sent = True
|
| 171 |
return {"type": "http.request", "body": full_body, "more_body": False}
|
| 172 |
+
result = await receive()
|
| 173 |
return result
|
| 174 |
|
| 175 |
# Wrap send to inject compression headers
|
| 176 |
+
async def metrics_send(message: MutableMapping[str, Any]) -> None:
|
| 177 |
if message["type"] == "http.response.start" and tokens_saved > 0:
|
| 178 |
headers = list(message.get("headers", []))
|
| 179 |
headers.append((b"x-headroom-compressed", b"true"))
|
|
@@ -103,7 +103,7 @@ class HeadroomCallback:
|
|
| 103 |
else:
|
| 104 |
result = self._local_compress(messages, model)
|
| 105 |
|
| 106 |
-
if result and result.get("tokens_saved", 0) > 0:
|
| 107 |
data["messages"] = result["messages"]
|
| 108 |
self._total_saved += result["tokens_saved"]
|
| 109 |
logger.info(
|
|
|
|
| 103 |
else:
|
| 104 |
result = self._local_compress(messages, model)
|
| 105 |
|
| 106 |
+
if result and result.get("tokens_saved", 0) > 0 and "messages" in result:
|
| 107 |
data["messages"] = result["messages"]
|
| 108 |
self._total_saved += result["tokens_saved"]
|
| 109 |
logger.info(
|
|
@@ -217,14 +217,14 @@ class SQLiteMemoryStore:
|
|
| 217 |
supersedes=row["supersedes"],
|
| 218 |
superseded_by=row["superseded_by"],
|
| 219 |
promoted_from=row["promoted_from"],
|
| 220 |
-
promotion_chain=json.loads(row["promotion_chain"]),
|
| 221 |
access_count=row["access_count"],
|
| 222 |
last_accessed=datetime.fromisoformat(row["last_accessed"])
|
| 223 |
if row["last_accessed"]
|
| 224 |
else None,
|
| 225 |
-
entity_refs=json.loads(row["entity_refs"]),
|
| 226 |
embedding=self._deserialize_embedding(row["embedding"]),
|
| 227 |
-
metadata=json.loads(row["metadata"]),
|
| 228 |
)
|
| 229 |
|
| 230 |
async def save(self, memory: Memory) -> None:
|
|
|
|
| 217 |
supersedes=row["supersedes"],
|
| 218 |
superseded_by=row["superseded_by"],
|
| 219 |
promoted_from=row["promoted_from"],
|
| 220 |
+
promotion_chain=json.loads(row["promotion_chain"]) if row["promotion_chain"] else [],
|
| 221 |
access_count=row["access_count"],
|
| 222 |
last_accessed=datetime.fromisoformat(row["last_accessed"])
|
| 223 |
if row["last_accessed"]
|
| 224 |
else None,
|
| 225 |
+
entity_refs=json.loads(row["entity_refs"]) if row["entity_refs"] else [],
|
| 226 |
embedding=self._deserialize_embedding(row["embedding"]),
|
| 227 |
+
metadata=json.loads(row["metadata"]) if row["metadata"] else {},
|
| 228 |
)
|
| 229 |
|
| 230 |
async def save(self, memory: Memory) -> None:
|
|
@@ -67,9 +67,10 @@ def detect_waste_signals(text: str, tokenizer: Tokenizer) -> WasteSignals:
|
|
| 67 |
# Excessive whitespace
|
| 68 |
ws_matches = WHITESPACE_PATTERN.findall(text)
|
| 69 |
if ws_matches:
|
| 70 |
-
# Count tokens that could be saved by normalizing
|
| 71 |
ws_text = "".join(ws_matches)
|
| 72 |
-
|
|
|
|
| 73 |
|
| 74 |
# Large JSON blocks
|
| 75 |
json_matches = JSON_BLOCK_PATTERN.findall(text)
|
|
|
|
| 67 |
# Excessive whitespace
|
| 68 |
ws_matches = WHITESPACE_PATTERN.findall(text)
|
| 69 |
if ws_matches:
|
| 70 |
+
# Count tokens that could be saved by normalizing whitespace to single spaces
|
| 71 |
ws_text = "".join(ws_matches)
|
| 72 |
+
normalized_text = " ".join(ws_matches)
|
| 73 |
+
signals.whitespace_tokens = max(0, tokenizer.count_text(ws_text) - tokenizer.count_text(normalized_text))
|
| 74 |
|
| 75 |
# Large JSON blocks
|
| 76 |
json_matches = JSON_BLOCK_PATTERN.findall(text)
|
|
@@ -2049,9 +2049,14 @@ class HeadroomProxy:
|
|
| 2049 |
logger.info("CCR: Parsed JSON successfully")
|
| 2050 |
return result
|
| 2051 |
except Exception as e:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2052 |
logger.error(
|
| 2053 |
f"CCR: API call failed: {e}, "
|
| 2054 |
-
f"response headers: {
|
| 2055 |
)
|
| 2056 |
raise
|
| 2057 |
|
|
@@ -2072,9 +2077,14 @@ class HeadroomProxy:
|
|
| 2072 |
for k, v in response.headers.items()
|
| 2073 |
if k.lower() not in ("content-encoding", "content-length")
|
| 2074 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2075 |
response = httpx.Response(
|
| 2076 |
status_code=200,
|
| 2077 |
-
content=
|
| 2078 |
headers=ccr_response_headers,
|
| 2079 |
)
|
| 2080 |
logger.info(f"[{request_id}] CCR: Retrieval handled successfully")
|
|
|
|
| 2049 |
logger.info("CCR: Parsed JSON successfully")
|
| 2050 |
return result
|
| 2051 |
except Exception as e:
|
| 2052 |
+
resp_headers: str | dict[str, str] = "N/A"
|
| 2053 |
+
try:
|
| 2054 |
+
resp_headers = dict(cont_response.headers)
|
| 2055 |
+
except Exception:
|
| 2056 |
+
pass
|
| 2057 |
logger.error(
|
| 2058 |
f"CCR: API call failed: {e}, "
|
| 2059 |
+
f"response headers: {resp_headers}"
|
| 2060 |
)
|
| 2061 |
raise
|
| 2062 |
|
|
|
|
| 2077 |
for k, v in response.headers.items()
|
| 2078 |
if k.lower() not in ("content-encoding", "content-length")
|
| 2079 |
}
|
| 2080 |
+
try:
|
| 2081 |
+
ccr_content = json.dumps(final_resp_json).encode()
|
| 2082 |
+
except (TypeError, ValueError) as json_err:
|
| 2083 |
+
logger.warning(f"[{request_id}] CCR: JSON serialization failed: {json_err}")
|
| 2084 |
+
ccr_content = json.dumps(resp_json).encode()
|
| 2085 |
response = httpx.Response(
|
| 2086 |
status_code=200,
|
| 2087 |
+
content=ccr_content,
|
| 2088 |
headers=ccr_response_headers,
|
| 2089 |
)
|
| 2090 |
logger.info(f"[{request_id}] CCR: Retrieval handled successfully")
|
|
@@ -134,7 +134,7 @@ class BM25Scorer(RelevanceScorer):
|
|
| 134 |
return 0.0, []
|
| 135 |
|
| 136 |
doc_len = len(doc_tokens)
|
| 137 |
-
avgdl = avg_doc_len or doc_len
|
| 138 |
|
| 139 |
doc_freq = Counter(doc_tokens)
|
| 140 |
query_freq = Counter(query_tokens)
|
|
|
|
| 134 |
return 0.0, []
|
| 135 |
|
| 136 |
doc_len = len(doc_tokens)
|
| 137 |
+
avgdl = avg_doc_len or doc_len or 1
|
| 138 |
|
| 139 |
doc_freq = Counter(doc_tokens)
|
| 140 |
query_freq = Counter(query_tokens)
|
|
@@ -471,7 +471,7 @@ def _get_top_waste_requests(
|
|
| 471 |
)
|
| 472 |
|
| 473 |
# Sort by tokens saved (waste potential)
|
| 474 |
-
requests.sort(key=lambda x: x["
|
| 475 |
|
| 476 |
return requests[:limit]
|
| 477 |
|
|
|
|
| 471 |
)
|
| 472 |
|
| 473 |
# Sort by tokens saved (waste potential)
|
| 474 |
+
requests.sort(key=lambda x: x["tokens_saved"], reverse=True)
|
| 475 |
|
| 476 |
return requests[:limit]
|
| 477 |
|
|
@@ -288,7 +288,7 @@ class ContentRouterConfig:
|
|
| 288 |
# compression. At 10 msgs, protects ~5 Reads. At 100 msgs, protects ~10.
|
| 289 |
# Old Reads beyond this window become compressible even though they are
|
| 290 |
# in DEFAULT_EXCLUDE_TOOLS. 0.0 = always exclude all (old behavior).
|
| 291 |
-
protect_recent_reads_fraction: float = 0.
|
| 292 |
|
| 293 |
# Adaptive compression ratio: scales with context pressure.
|
| 294 |
# At low pressure (<30% full), use the relaxed threshold (reject marginal).
|
|
|
|
| 288 |
# compression. At 10 msgs, protects ~5 Reads. At 100 msgs, protects ~10.
|
| 289 |
# Old Reads beyond this window become compressible even though they are
|
| 290 |
# in DEFAULT_EXCLUDE_TOOLS. 0.0 = always exclude all (old behavior).
|
| 291 |
+
protect_recent_reads_fraction: float = 0.0 # 0.0 = protect ALL excluded-tool outputs (safest for coding agents)
|
| 292 |
|
| 293 |
# Adaptive compression ratio: scales with context pressure.
|
| 294 |
# At low pressure (<30% full), use the relaxed threshold (reject marginal).
|
|
@@ -368,6 +368,10 @@ def _detect_sequential_pattern(values: list[Any], check_order: bool = True) -> b
|
|
| 368 |
if len(nums) < 5:
|
| 369 |
return False
|
| 370 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 371 |
# Check if sorted values form a near-sequence
|
| 372 |
sorted_nums = sorted(nums)
|
| 373 |
diffs = [sorted_nums[i + 1] - sorted_nums[i] for i in range(len(sorted_nums) - 1)]
|
|
@@ -500,12 +504,13 @@ def _detect_score_field_statistically(stats: FieldStats, items: list[dict]) -> t
|
|
| 500 |
values_in_order.append(float(val))
|
| 501 |
if len(values_in_order) >= 5:
|
| 502 |
# Check for descending sort
|
|
|
|
| 503 |
descending_count = sum(
|
| 504 |
1
|
| 505 |
-
for i in range(
|
| 506 |
if values_in_order[i] >= values_in_order[i + 1]
|
| 507 |
)
|
| 508 |
-
if
|
| 509 |
confidence += 0.3
|
| 510 |
|
| 511 |
# Score fields often have floating point values
|
|
|
|
| 368 |
if len(nums) < 5:
|
| 369 |
return False
|
| 370 |
|
| 371 |
+
# Need at least 2 elements for pairwise comparison
|
| 372 |
+
if len(nums) < 2:
|
| 373 |
+
return False
|
| 374 |
+
|
| 375 |
# Check if sorted values form a near-sequence
|
| 376 |
sorted_nums = sorted(nums)
|
| 377 |
diffs = [sorted_nums[i + 1] - sorted_nums[i] for i in range(len(sorted_nums) - 1)]
|
|
|
|
| 504 |
values_in_order.append(float(val))
|
| 505 |
if len(values_in_order) >= 5:
|
| 506 |
# Check for descending sort
|
| 507 |
+
num_pairs = len(values_in_order) - 1
|
| 508 |
descending_count = sum(
|
| 509 |
1
|
| 510 |
+
for i in range(num_pairs)
|
| 511 |
if values_in_order[i] >= values_in_order[i + 1]
|
| 512 |
)
|
| 513 |
+
if num_pairs > 0 and descending_count / num_pairs > 0.7:
|
| 514 |
confidence += 0.3
|
| 515 |
|
| 516 |
# Score fields often have floating point values
|