Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion benchmark/evaluate.py
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ def run_cordon_analysis(
scored = scorer.score_windows(embedded, config)

# extract embeddings and scores for later use
embeddings = np.array([sw.embedding for sw in scored])
embeddings = np.array([emb for _, emb in embedded])
scores = np.array([sw.score for sw in scored])
window_ranges = [(w.window.start_line, w.window.end_line) for w in scored]

Expand Down
1 change: 1 addition & 0 deletions src/cordon/analysis/scorer.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ def _score_windows(
desc=_SCORING_PROGRESS_DESC,
unit="batch",
total=(n_samples + query_batch_size - 1) // query_batch_size,
disable=not config.show_progress,
):
batch_end = min(batch_start + query_batch_size, n_samples)
batch_embeddings = embeddings_tensor[batch_start:batch_end]
Expand Down
16 changes: 16 additions & 0 deletions src/cordon/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,20 @@ def parse_args() -> argparse.Namespace:
action="store_true",
help="Overwrite output file if it exists",
)
output_group.add_argument(
"--format",
type=str,
choices=["xml", "json"],
default="xml",
dest="output_format",
help="Output format for anomaly blocks (default: xml)",
)
output_group.add_argument(
"--quiet",
"-q",
action="store_true",
help="Suppress progress bars (useful for CI or library usage)",
)

return parser.parse_args()

Expand Down Expand Up @@ -322,6 +336,8 @@ def _main_impl() -> None:
n_ctx=args.n_ctx,
api_key=args.api_key,
endpoint=args.endpoint,
show_progress=not args.quiet,
output_format=args.output_format,
)
except ValueError as error:
print(f"Configuration error: {error}", file=sys.stderr)
Expand Down
10 changes: 10 additions & 0 deletions src/cordon/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ class AnalysisConfig:
api_key: API key for remote embedding providers.
endpoint: Custom API endpoint URL (remote backend).
request_timeout: HTTP request timeout in seconds (remote backend).
show_progress: Whether to display tqdm progress bars during
embedding and scoring. Set to False for CI or library use.
output_format: Output format for anomaly blocks.
"""

window_size: int = 4
Expand All @@ -87,6 +90,8 @@ class AnalysisConfig:
api_key: str | None = None
endpoint: str | None = None
request_timeout: float = 60.0
show_progress: bool = True
output_format: Literal["xml", "json"] = "xml"

def __post_init__(self) -> None:
"""Validate configuration parameters."""
Expand All @@ -112,6 +117,11 @@ def _validate_core_params(self) -> None:
raise ValueError("scoring_batch_size must be >= 1 or None for auto-detect")
if self.device is not None and self.device not in ("cuda", "mps", "cpu"):
raise ValueError("device must be 'cuda', 'mps', 'cpu', or None")
_ALLOWED_FORMATS = ("xml", "json")
if self.output_format not in _ALLOWED_FORMATS:
raise ValueError(
f"output_format must be one of {_ALLOWED_FORMATS}, got {self.output_format!r}"
)

def _validate_anomaly_range(self) -> None:
"""Validate anomaly range parameters."""
Expand Down
2 changes: 2 additions & 0 deletions src/cordon/core/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ class AnalysisResult:

Attributes:
output: Formatted output string with XML tags.
blocks: Structured list of merged anomaly blocks.
total_lines: Total number of lines in the input file.
Comment on lines 92 to 94

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Update AnalysisResult.output docstring to reflect JSON support.

output is documented as XML-only, but this PR introduces JSON output too. Please make the attribute description format-agnostic.

Proposed patch
-        output: Formatted output string with XML tags.
+        output: Formatted output string (XML or JSON, depending on configuration).
         blocks: Structured list of merged anomaly blocks.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
output: Formatted output string with XML tags.
blocks: Structured list of merged anomaly blocks.
total_lines: Total number of lines in the input file.
output: Formatted output string (XML or JSON, depending on configuration).
blocks: Structured list of merged anomaly blocks.
total_lines: Total number of lines in the input file.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/cordon/core/types.py` around lines 92 - 94, The docstring for
AnalysisResult.output currently describes only XML; update it to be
format-agnostic by changing the description to something like "Formatted output
string (e.g., XML or JSON) representing the analysis results" so it no longer
implies XML-only; modify the docstring on the AnalysisResult.output attribute in
src/cordon/core/types.py and ensure neighboring descriptions (blocks,
total_lines) remain unchanged.

total_windows: Total number of windows created.
significant_windows: Number of windows above threshold.
Expand All @@ -99,6 +100,7 @@ class AnalysisResult:
"""

output: str
blocks: list[MergedBlock]
total_lines: int
total_windows: int
significant_windows: int
Expand Down
1 change: 1 addition & 0 deletions src/cordon/embedding/llama_cpp.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ def embed_windows(
desc="Generating embeddings",
total=total_batches,
unit="batch",
disable=not self.config.show_progress,
):
batch = window_list[batch_start : batch_start + batch_size]
texts = [w.content for w in batch]
Expand Down
1 change: 1 addition & 0 deletions src/cordon/embedding/remote.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ def embed_windows(
desc="Generating embeddings",
total=total_batches,
unit="batch",
disable=not self.config.show_progress,
):
batch = window_list[batch_start_idx : batch_start_idx + batch_size]
texts = [window.content for window in batch]
Expand Down
1 change: 1 addition & 0 deletions src/cordon/embedding/transformer.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ def embed_windows(
desc="Generating embeddings",
total=total_batches,
unit="batch",
disable=not self.config.show_progress,
):
batch = window_list[batch_start_idx : batch_start_idx + batch_size]
texts = [window.content for window in batch]
Expand Down
12 changes: 10 additions & 2 deletions src/cordon/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,14 @@ def __init__(
self._scorer = scorer if scorer is not None else DensityAnomalyScorer()
self._thresholder = thresholder if thresholder is not None else ThresholderImpl()
self._merger = merger if merger is not None else IntervalMerger()
self._formatter = formatter if formatter is not None else XmlFormatter()
if formatter is not None:
self._formatter = formatter
elif self.config.output_format == "json":
from cordon.postprocess.json_formatter import JsonFormatter

self._formatter = JsonFormatter()
else:
self._formatter = XmlFormatter()

def analyze_file(self, file_path: Path) -> str:
"""Analyze a log file and return formatted output.
Expand All @@ -69,7 +76,7 @@ def analyze_file(self, file_path: Path) -> str:
file_path: Path to the log file to analyze.

Returns:
Formatted string with XML-tagged significant blocks.
Formatted string with significant blocks (XML or JSON, per output_format).
"""
result = self.analyze_file_detailed(file_path)
return result.output
Expand Down Expand Up @@ -119,6 +126,7 @@ def analyze_file_detailed(self, file_path: Path) -> AnalysisResult:

return AnalysisResult(
output=output,
blocks=list(merged),
total_lines=total_lines,
total_windows=total_windows,
significant_windows=significant_windows,
Expand Down
3 changes: 2 additions & 1 deletion src/cordon/postprocess/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from cordon.postprocess.formatter import XmlFormatter
from cordon.postprocess.json_formatter import JsonFormatter
from cordon.postprocess.merger import IntervalMerger

__all__ = ["IntervalMerger", "XmlFormatter"]
__all__ = ["IntervalMerger", "JsonFormatter", "XmlFormatter"]
48 changes: 48 additions & 0 deletions src/cordon/postprocess/json_formatter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
"""JSON output formatter for anomaly blocks."""

import json
from collections.abc import Sequence

from cordon.core.types import MergedBlock


class JsonFormatter:
"""Format merged blocks as a JSON document.

Produces a JSON object with an 'anomalies' array where each entry
contains start_line, end_line, score, and the original line content.
"""

def format_blocks(
self,
merged_blocks: Sequence[MergedBlock],
lines: Sequence[tuple[int, str]],
) -> str:
"""Format merged blocks into a JSON string.

Args:
merged_blocks: Sequence of merged blocks to format.
lines: Sequence of (line_number, line_content) tuples.

Returns:
JSON string with anomaly blocks.
"""
sorted_blocks = sorted(merged_blocks, key=lambda b: b.start_line)
line_map = dict(lines)

anomalies = []
for block in sorted_blocks:
content_lines = []
for line_num in range(block.start_line, block.end_line + 1):
content_lines.append(line_map.get(line_num, ""))

anomalies.append(
{
"start_line": block.start_line,
"end_line": block.end_line,
"score": round(block.max_score, 4),
"content": "\n".join(content_lines),
}
)

return json.dumps({"anomalies": anomalies}, indent=2)
12 changes: 12 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,18 @@ def test_detailed_flag(self, monkeypatch: pytest.MonkeyPatch) -> None:
args = parse_args()
assert args.detailed is True

def test_format_json(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""Test --format json flag."""
monkeypatch.setattr(sys, "argv", ["cordon", "--format", "json", "test.log"])
args = parse_args()
assert args.output_format == "json"

def test_format_default_xml(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""Test that --format defaults to xml."""
monkeypatch.setattr(sys, "argv", ["cordon", "test.log"])
args = parse_args()
assert args.output_format == "xml"


class TestAnalyzeFile:
"""Tests for analyze_file function."""
Expand Down
51 changes: 50 additions & 1 deletion tests/test_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import pytest

from cordon.core.config import AnalysisConfig
from cordon.core.types import MergedBlock, ScoredWindow, TextWindow
from cordon.core.types import AnalysisResult, MergedBlock, ScoredWindow, TextWindow


class TestTextWindow:
Expand Down Expand Up @@ -167,3 +167,52 @@ def test_max_line_length_validation(self) -> None:
assert config.max_line_length is None
config = AnalysisConfig(max_line_length=500)
assert config.max_line_length == 500

def test_output_format_default(self) -> None:
"""Test that output_format defaults to xml."""
config = AnalysisConfig()
assert config.output_format == "xml"

def test_output_format_json(self) -> None:
"""Test that output_format can be set to json."""
config = AnalysisConfig(output_format="json")
assert config.output_format == "json"


class TestAnalysisResult:
"""Tests for AnalysisResult dataclass."""

def test_blocks_field_populated(self) -> None:
"""Test that blocks field stores MergedBlock instances."""
blocks = [
MergedBlock(start_line=1, end_line=4, original_windows=(0,), max_score=0.5),
MergedBlock(start_line=10, end_line=16, original_windows=(2, 3), max_score=0.9),
]
result = AnalysisResult(
output="<output>",
blocks=blocks,
total_lines=100,
total_windows=25,
significant_windows=3,
merged_blocks=2,
score_distribution={"min": 0.0, "max": 1.0, "mean": 0.5, "median": 0.5, "p90": 0.9},
processing_time=1.0,
)
assert result.blocks == blocks
assert len(result.blocks) == 2
assert result.blocks[0].start_line == 1
assert result.blocks[1].max_score == 0.9

def test_empty_blocks(self) -> None:
"""Test that blocks field can be empty."""
result = AnalysisResult(
output="",
blocks=[],
total_lines=0,
total_windows=0,
significant_windows=0,
merged_blocks=0,
score_distribution={"min": 0.0, "max": 0.0, "mean": 0.0, "median": 0.0, "p90": 0.0},
processing_time=0.0,
)
assert result.blocks == []
1 change: 1 addition & 0 deletions tests/test_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ def test_analyze_simple_log(self) -> None:
assert "min" in result.score_distribution
assert "max" in result.score_distribution
assert result.output is not None
assert isinstance(result.blocks, list)
finally:
temp_path.unlink()

Expand Down
62 changes: 62 additions & 0 deletions tests/test_json_formatter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"""Tests for JsonFormatter."""

import json

from cordon.core.types import MergedBlock
from cordon.postprocess.json_formatter import JsonFormatter


class TestJsonFormatter:
"""Tests for JsonFormatter output."""

def test_format_single_block(self) -> None:
"""Test formatting a single anomaly block."""
lines = [(1, "line 1"), (2, "line 2"), (3, "line 3")]
blocks = [MergedBlock(start_line=1, end_line=2, original_windows=(0,), max_score=0.8)]
formatter = JsonFormatter()
output = formatter.format_blocks(blocks, lines)
data = json.loads(output)
assert len(data["anomalies"]) == 1
assert data["anomalies"][0]["start_line"] == 1
assert data["anomalies"][0]["end_line"] == 2
assert data["anomalies"][0]["score"] == 0.8
assert "line 1" in data["anomalies"][0]["content"]

def test_format_multiple_blocks(self) -> None:
"""Test formatting multiple anomaly blocks."""
lines = [(i, f"line {i}") for i in range(1, 11)]
blocks = [
MergedBlock(start_line=1, end_line=2, original_windows=(0,), max_score=0.8),
MergedBlock(start_line=7, end_line=9, original_windows=(1,), max_score=0.9),
]
formatter = JsonFormatter()
output = formatter.format_blocks(blocks, lines)
data = json.loads(output)
assert len(data["anomalies"]) == 2
assert data["anomalies"][0]["score"] == 0.8
assert data["anomalies"][1]["score"] == 0.9

def test_format_empty_blocks(self) -> None:
"""Test formatting with no blocks produces empty anomalies array."""
lines = [(1, "line 1")]
formatter = JsonFormatter()
output = formatter.format_blocks([], lines)
data = json.loads(output)
assert data["anomalies"] == []

def test_no_xml_escaping_in_json(self) -> None:
"""Test that special characters are preserved without XML escaping."""
lines = [(1, "x < y && z > 10")]
blocks = [MergedBlock(start_line=1, end_line=1, original_windows=(0,), max_score=0.5)]
formatter = JsonFormatter()
output = formatter.format_blocks(blocks, lines)
data = json.loads(output)
assert "x < y && z > 10" in data["anomalies"][0]["content"]

def test_output_is_valid_json(self) -> None:
"""Test that output is always valid JSON."""
lines = [(1, "test")]
blocks = [MergedBlock(start_line=1, end_line=1, original_windows=(0,), max_score=0.1234)]
formatter = JsonFormatter()
output = formatter.format_blocks(blocks, lines)
json.loads(output)
Loading
Loading