From a16ab770701d95162519bfb42731a9e1ba0bd8de Mon Sep 17 00:00:00 2001 From: Caleb Evans Date: Fri, 22 May 2026 17:17:15 -0600 Subject: [PATCH 1/3] feat: add token budget mode for LLM context window fitting - Add --token-budget flag to dynamically compute anomaly_percentile based on input file token count, ensuring output fits within a specified token budget - Use tiktoken (cl100k_base) for accurate token counting - Add --tokenizer-encoding flag for customizing the tokenizer - Token budget overrides anomaly_percentile when both are set - Add tiktoken to dependencies - Add tests for budget computation and edge cases Co-authored-by: Cursor --- pyproject.toml | 5 +++++ src/cordon/cli.py | 20 +++++++++++++++++++ src/cordon/core/config.py | 9 +++++++++ src/cordon/pipeline.py | 27 ++++++++++++++++++++++++- tests/test_cli.py | 26 ++++++++++++++++++++++++ tests/test_core.py | 9 +++++++++ tests/test_pipeline_unit.py | 40 +++++++++++++++++++++++++++++++++++++ 7 files changed, 135 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 2ac981a..cd1f445 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ dependencies = [ "tqdm>=4.65.0", "litellm>=1.83.0", "tokenizers>=0.20.0", + "tiktoken", ] [project.scripts] @@ -89,6 +90,10 @@ ignore_missing_imports = true module = "litellm.*" ignore_missing_imports = true +[[tool.mypy.overrides]] +module = "tiktoken.*" +ignore_missing_imports = true + [tool.ruff] line-length = 100 target-version = "py310" diff --git a/src/cordon/cli.py b/src/cordon/cli.py index 27065bb..03e05b9 100644 --- a/src/cordon/cli.py +++ b/src/cordon/cli.py @@ -128,6 +128,18 @@ def parse_args() -> argparse.Namespace: default=None, help="Batch size for k-NN scoring queries (default: auto-detect based on GPU memory)", ) + config_group.add_argument( + "--token-budget", + type=int, + default=None, + help="Maximum token budget for output; dynamically adjusts percentile to fit (overrides --anomaly-percentile)", + ) + config_group.add_argument( + "--tokenizer-encoding", + type=str, + default="cl100k_base", + help="tiktoken encoding for token counting (default: cl100k_base)", + ) # output options output_group = parser.add_argument_group("output options") @@ -355,6 +367,12 @@ def _main_impl() -> None: file=sys.stderr, ) + if args.token_budget is not None and not isclose(args.anomaly_percentile, 0.1): + print( + "Warning: --anomaly-percentile is overridden by --token-budget", + file=sys.stderr, + ) + # create configuration from arguments try: config = AnalysisConfig( @@ -376,6 +394,8 @@ def _main_impl() -> None: api_key=args.api_key, endpoint=args.endpoint, show_progress=not args.quiet, + token_budget=args.token_budget, + tokenizer_encoding=args.tokenizer_encoding, output_format=args.output_format, ) except ValueError as error: diff --git a/src/cordon/core/config.py b/src/cordon/core/config.py index ffaf00e..874646f 100644 --- a/src/cordon/core/config.py +++ b/src/cordon/core/config.py @@ -69,6 +69,11 @@ class AnalysisConfig: 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. + token_budget: Maximum token count for output. When set, dynamically + computes anomaly_percentile from the input size to fit + the output within this budget. Overrides anomaly_percentile. + tokenizer_encoding: tiktoken encoding name for token counting + (default: cl100k_base, used by GPT-4/GPT-3.5). output_format: Output format for anomaly blocks. """ @@ -91,6 +96,8 @@ class AnalysisConfig: endpoint: str | None = None request_timeout: float = 60.0 show_progress: bool = True + token_budget: int | None = None + tokenizer_encoding: str = "cl100k_base" output_format: Literal["xml", "json"] = "xml" def __post_init__(self) -> None: @@ -122,6 +129,8 @@ def _validate_core_params(self) -> None: raise ValueError( f"output_format must be one of {_ALLOWED_FORMATS}, got {self.output_format!r}" ) + if self.token_budget is not None and self.token_budget < 1: + raise ValueError("token_budget must be >= 1 if set") def _validate_anomaly_range(self) -> None: """Validate anomaly range parameters.""" diff --git a/src/cordon/pipeline.py b/src/cordon/pipeline.py index caba6a6..52021a2 100644 --- a/src/cordon/pipeline.py +++ b/src/cordon/pipeline.py @@ -1,5 +1,7 @@ +import logging import time from collections.abc import Sequence +from dataclasses import replace from pathlib import Path import numpy as np @@ -23,6 +25,8 @@ from cordon.postprocess.merger import IntervalMerger from cordon.segmentation.windower import SlidingWindowSegmenter +logger = logging.getLogger(__name__) + class SemanticLogAnalyzer: """High-level API for semantic log analysis. @@ -153,7 +157,28 @@ def _analyze_lines(self, lines_list: list[tuple[int, str]]) -> AnalysisResult: del embedded # stage 5: thresholding - significant = self._thresholder.select_significant(scored, self.config) + thresholding_config = self.config + if self.config.token_budget is not None: + import tiktoken + + enc = tiktoken.get_encoding(self.config.tokenizer_encoding) + total_tokens = sum(len(enc.encode(text)) for _, text in lines_list) + + if total_tokens > 0: + budget_percentile = min(self.config.token_budget / total_tokens, 1.0) + else: + budget_percentile = 1.0 + + thresholding_config = replace(self.config, anomaly_percentile=budget_percentile) + + logger.info( + "Token budget: %d/%d tokens (%.1f%% percentile)", + self.config.token_budget, + total_tokens, + budget_percentile * 100, + ) + + significant = self._thresholder.select_significant(scored, thresholding_config) significant_windows = len(significant) # stage 6: merging diff --git a/tests/test_cli.py b/tests/test_cli.py index 9236070..b3cacea 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -90,6 +90,32 @@ def test_stdin_argument(self, monkeypatch: pytest.MonkeyPatch) -> None: args = parse_args() assert str(args.logfiles[0]) == "-" + def test_token_budget(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Test --token-budget parsing.""" + monkeypatch.setattr(sys, "argv", ["cordon", "--token-budget", "500", "test.log"]) + args = parse_args() + assert args.token_budget == 500 + + def test_token_budget_default(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Test that --token-budget defaults to None.""" + monkeypatch.setattr(sys, "argv", ["cordon", "test.log"]) + args = parse_args() + assert args.token_budget is None + + def test_tokenizer_encoding(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Test --tokenizer-encoding parsing.""" + monkeypatch.setattr( + sys, "argv", ["cordon", "--tokenizer-encoding", "p50k_base", "test.log"] + ) + args = parse_args() + assert args.tokenizer_encoding == "p50k_base" + + def test_tokenizer_encoding_default(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Test that --tokenizer-encoding defaults to cl100k_base.""" + monkeypatch.setattr(sys, "argv", ["cordon", "test.log"]) + args = parse_args() + assert args.tokenizer_encoding == "cl100k_base" + class TestAnalyzeFile: """Tests for analyze_file function.""" diff --git a/tests/test_core.py b/tests/test_core.py index 8e07112..d33bc12 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -178,6 +178,15 @@ def test_output_format_json(self) -> None: config = AnalysisConfig(output_format="json") assert config.output_format == "json" + def test_token_budget_validation(self) -> None: + """Test that invalid token_budget values are rejected.""" + with pytest.raises(ValueError, match="token_budget"): + AnalysisConfig(token_budget=0) + with pytest.raises(ValueError, match="token_budget"): + AnalysisConfig(token_budget=-1) + config = AnalysisConfig(token_budget=1000) + assert config.token_budget == 1000 + class TestAnalysisResult: """Tests for AnalysisResult dataclass.""" diff --git a/tests/test_pipeline_unit.py b/tests/test_pipeline_unit.py index 4b15bd4..e53a207 100644 --- a/tests/test_pipeline_unit.py +++ b/tests/test_pipeline_unit.py @@ -184,3 +184,43 @@ def test_analyze_lines(self, mock_create: MagicMock) -> None: lines: list[tuple[int, str]] = [(1, "hello"), (2, "world")] result = analyzer.analyze_lines(lines) assert result.total_lines == 2 + + +class TestTokenBudget: + """Tests for token budget mode.""" + + @patch("cordon.pipeline.create_embedder") + def test_token_budget_computes_percentile(self, mock_create: MagicMock) -> None: + """Test that token budget dynamically adjusts percentile.""" + mock_embedder = MagicMock() + mock_create.return_value = mock_embedder + mock_embedder.embed_windows.return_value = iter([]) + + config = AnalysisConfig(device="cpu", token_budget=100) + analyzer = SemanticLogAnalyzer(config) + result = analyzer.analyze_text_detailed("word " * 100) + assert result.total_lines >= 1 + + @patch("cordon.pipeline.create_embedder") + def test_token_budget_larger_than_file(self, mock_create: MagicMock) -> None: + """Test that budget larger than file results in percentile capped at 1.0.""" + mock_embedder = MagicMock() + mock_create.return_value = mock_embedder + mock_embedder.embed_windows.return_value = iter([]) + + config = AnalysisConfig(device="cpu", token_budget=999999) + analyzer = SemanticLogAnalyzer(config) + result = analyzer.analyze_text_detailed("short text") + assert result.total_lines >= 1 + + @patch("cordon.pipeline.create_embedder") + def test_token_budget_empty_input(self, mock_create: MagicMock) -> None: + """Test token budget with empty input.""" + mock_embedder = MagicMock() + mock_create.return_value = mock_embedder + mock_embedder.embed_windows.return_value = iter([]) + + config = AnalysisConfig(device="cpu", token_budget=100) + analyzer = SemanticLogAnalyzer(config) + result = analyzer.analyze_text_detailed("") + assert result.total_lines == 0 From c8e912cc75b9ad6f5c8b0783e5959d851de48042 Mon Sep 17 00:00:00 2001 From: Caleb Evans Date: Fri, 22 May 2026 17:23:41 -0600 Subject: [PATCH 2/3] feat: add quiet banner suppression, CI exit codes, and output filtering - Make --quiet suppress all human-readable banners and status output, keeping only formatted XML/JSON on stdout for clean piping - Add --fail-if-anomalies flag that exits with code 2 when anomalies are found, enabling CI pipeline gating - Add --max-blocks to limit output to the top N highest-scoring blocks - Add --min-score to filter out blocks below a score threshold - Update tests for quiet mode, exit codes, and filtering Co-authored-by: Cursor --- src/cordon/cli.py | 98 +++++++++++++++++++++-------- src/cordon/core/config.py | 10 +++ src/cordon/pipeline.py | 13 +++- tests/test_cli.py | 119 +++++++++++++++++++++++++++++++++++- tests/test_core.py | 22 +++++++ tests/test_pipeline_unit.py | 117 +++++++++++++++++++++++++++++++++++ 6 files changed, 352 insertions(+), 27 deletions(-) diff --git a/src/cordon/cli.py b/src/cordon/cli.py index 03e05b9..511cee8 100644 --- a/src/cordon/cli.py +++ b/src/cordon/cli.py @@ -140,6 +140,18 @@ def parse_args() -> argparse.Namespace: default="cl100k_base", help="tiktoken encoding for token counting (default: cl100k_base)", ) + config_group.add_argument( + "--max-blocks", + type=int, + default=None, + help="Maximum number of anomaly blocks to output (keeps highest scoring)", + ) + config_group.add_argument( + "--min-score", + type=float, + default=None, + help="Minimum anomaly score threshold for output blocks", + ) # output options output_group = parser.add_argument_group("output options") @@ -172,7 +184,12 @@ def parse_args() -> argparse.Namespace: "--quiet", "-q", action="store_true", - help="Suppress progress bars (useful for CI or library usage)", + help="Suppress all human-readable banners and progress bars, keeping only formatted output on stdout", + ) + output_group.add_argument( + "--fail-if-anomalies", + action="store_true", + help="Exit with code 2 if anomalies are found (useful for CI gating)", ) return parser.parse_args() @@ -224,6 +241,7 @@ def _display_results( detailed: bool, output_path: Path | None, force: bool, + quiet: bool, ) -> None: """Display analysis results, optionally with detailed statistics. @@ -232,8 +250,9 @@ def _display_results( detailed: Whether to print detailed statistics before the output. output_path: Optional path to save anomalous blocks (None prints to stdout). force: If True, overwrite an existing output file. + quiet: If True, suppress human-readable banners and stats. """ - if detailed: + if detailed and not quiet: print(f"Total lines: {result.total_lines:,}") print("\nAnalysis Statistics:") print(f" Total windows created: {result.total_windows:,}") @@ -255,7 +274,8 @@ def _display_results( else: print(result.output) - print() + if not quiet: + print() def analyze_file( @@ -264,7 +284,8 @@ def analyze_file( detailed: bool, output_path: Path | None = None, force: bool = False, -) -> None: + quiet: bool = False, +) -> bool: """Analyze a single log file and print results. Args: @@ -273,21 +294,28 @@ def analyze_file( detailed: Whether to show detailed statistics. output_path: Optional path to save anomalous blocks (None prints to stdout). force: If True, overwrite an existing output file. + quiet: If True, suppress human-readable banners and stats. + + Returns: + True if anomalies were found, False otherwise. """ if not _validate_file(log_path): - return + return False - print("=" * 80) - print(f"Analyzing: {log_path}") - print("=" * 80) + if not quiet: + print("=" * 80) + print(f"Analyzing: {log_path}") + print("=" * 80) try: result = analyzer.analyze_file_detailed(log_path) except Exception as error: print(f"Error analyzing {log_path}: {error}", file=sys.stderr) - return + return False + + _display_results(result, detailed, output_path, force, quiet) - _display_results(result, detailed, output_path, force) + return result.merged_blocks > 0 def analyze_stdin( @@ -295,7 +323,8 @@ def analyze_stdin( detailed: bool, output_path: Path | None = None, force: bool = False, -) -> None: + quiet: bool = False, +) -> bool: """Analyze log data from stdin and print results. Args: @@ -303,20 +332,27 @@ def analyze_stdin( detailed: Whether to show detailed statistics. output_path: Optional path to save anomalous blocks (None prints to stdout). force: If True, overwrite an existing output file. + quiet: If True, suppress human-readable banners and stats. + + Returns: + True if anomalies were found, False otherwise. """ text = sys.stdin.read() - print("=" * 80) - print("Analyzing: ") - print("=" * 80) + if not quiet: + print("=" * 80) + print("Analyzing: ") + print("=" * 80) try: result = analyzer.analyze_text_detailed(text) except Exception as error: print(f"Error analyzing : {error}", file=sys.stderr) - return + return False + + _display_results(result, detailed, output_path, force, quiet) - _display_results(result, detailed, output_path, force) + return result.merged_blocks > 0 def _print_backend_info(config: AnalysisConfig) -> None: @@ -397,16 +433,18 @@ def _main_impl() -> None: token_budget=args.token_budget, tokenizer_encoding=args.tokenizer_encoding, output_format=args.output_format, + max_blocks=args.max_blocks, + min_score=args.min_score, ) except ValueError as error: print(f"Configuration error: {error}", file=sys.stderr) sys.exit(1) - # create analyzer - print("Initializing analyzer...") - _print_backend_info(config) - _print_filtering_mode(config) - print() + if not args.quiet: + print("Initializing analyzer...") + _print_backend_info(config) + _print_filtering_mode(config) + print() try: analyzer = SemanticLogAnalyzer(config) @@ -419,14 +457,26 @@ def _main_impl() -> None: except Exception as error: print(f"Initialization error: {error}", file=sys.stderr) sys.exit(1) - print() + + if not args.quiet: + print() # analyze each log file + any_anomalies_found = False for log_path in args.logfiles: if str(log_path) == "-": - analyze_stdin(analyzer, args.detailed, args.output, args.force) + found = analyze_stdin( + analyzer, args.detailed, args.output, args.force, quiet=args.quiet + ) else: - analyze_file(log_path, analyzer, args.detailed, args.output, args.force) + found = analyze_file( + log_path, analyzer, args.detailed, args.output, args.force, quiet=args.quiet + ) + if found: + any_anomalies_found = True + + if args.fail_if_anomalies and any_anomalies_found: + sys.exit(2) def main() -> None: diff --git a/src/cordon/core/config.py b/src/cordon/core/config.py index 874646f..5fc7504 100644 --- a/src/cordon/core/config.py +++ b/src/cordon/core/config.py @@ -75,6 +75,10 @@ class AnalysisConfig: tokenizer_encoding: tiktoken encoding name for token counting (default: cl100k_base, used by GPT-4/GPT-3.5). output_format: Output format for anomaly blocks. + max_blocks: Maximum number of anomaly blocks to include in output. + Keeps the highest-scoring blocks. None disables the limit. + min_score: Minimum anomaly score threshold. Blocks below this + score are excluded from output. None disables the threshold. """ window_size: int = 4 @@ -99,6 +103,8 @@ class AnalysisConfig: token_budget: int | None = None tokenizer_encoding: str = "cl100k_base" output_format: Literal["xml", "json"] = "xml" + max_blocks: int | None = None + min_score: float | None = None def __post_init__(self) -> None: """Validate configuration parameters.""" @@ -131,6 +137,10 @@ def _validate_core_params(self) -> None: ) if self.token_budget is not None and self.token_budget < 1: raise ValueError("token_budget must be >= 1 if set") + if self.max_blocks is not None and self.max_blocks < 1: + raise ValueError("max_blocks must be >= 1 if set") + if self.min_score is not None and self.min_score < 0: + raise ValueError("min_score must be >= 0 if set") def _validate_anomaly_range(self) -> None: """Validate anomaly range parameters.""" diff --git a/src/cordon/pipeline.py b/src/cordon/pipeline.py index 52021a2..6d7c88b 100644 --- a/src/cordon/pipeline.py +++ b/src/cordon/pipeline.py @@ -183,9 +183,20 @@ def _analyze_lines(self, lines_list: list[tuple[int, str]]) -> AnalysisResult: # stage 6: merging merged = self._merger.merge_windows(significant) - merged_blocks_count = len(merged) del significant + # stage 6b: post-merge filters + if self.config.min_score is not None: + merged = [b for b in merged if b.max_score >= self.config.min_score] + + if self.config.max_blocks is not None and len(merged) > self.config.max_blocks: + merged = sorted(merged, key=lambda b: b.max_score, reverse=True)[ + : self.config.max_blocks + ] + merged = sorted(merged, key=lambda b: b.start_line) + + merged_blocks_count = len(merged) + # stage 7: formatting output = self._formatter.format_blocks(merged, lines_list) diff --git a/tests/test_cli.py b/tests/test_cli.py index b3cacea..8e94c62 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -6,7 +6,7 @@ import pytest -from cordon.cli import analyze_file, parse_args +from cordon.cli import analyze_file, analyze_stdin, parse_args class TestParseArgs: @@ -200,8 +200,10 @@ def test_overwrite_protection(self, capsys: pytest.CaptureFixture[str], tmp_path analyzer = MagicMock() mock_result = MagicMock() - mock_result.output = "" + mock_result.output = "" + mock_result.merged_blocks = 0 analyzer.analyze_file_detailed.return_value = mock_result + analyze_file( log_file, analyzer, @@ -225,6 +227,7 @@ def test_force_overwrite(self, tmp_path: Path) -> None: analyzer = MagicMock() mock_result = MagicMock() mock_result.output = "new" + mock_result.merged_blocks = 1 analyzer.analyze_file_detailed.return_value = mock_result analyze_file( @@ -244,6 +247,7 @@ def test_stdout_output(self, capsys: pytest.CaptureFixture[str], tmp_path: Path) analyzer = MagicMock() mock_result = MagicMock() mock_result.output = "results" + mock_result.merged_blocks = 0 analyzer.analyze_file_detailed.return_value = mock_result analyze_file(log_file, analyzer, detailed=False) @@ -281,6 +285,117 @@ def test_detailed_output(self, capsys: pytest.CaptureFixture[str], tmp_path: Pat assert "Score Distribution:" in captured.out +class TestQuietMode: + """Tests for --quiet banner suppression.""" + + def test_quiet_suppresses_banners( + self, capsys: pytest.CaptureFixture[str], tmp_path: Path + ) -> None: + """Test that --quiet suppresses all human-readable banners.""" + log_file = tmp_path / "test.log" + log_file.write_text("line 1\n") + + analyzer = MagicMock() + mock_result = MagicMock() + mock_result.output = "" + mock_result.merged_blocks = 0 + analyzer.analyze_file_detailed.return_value = mock_result + + analyze_file(log_file, analyzer, detailed=True, quiet=True) + captured = capsys.readouterr() + assert "Analyzing:" not in captured.out + assert "=" * 80 not in captured.out + assert "Total lines" not in captured.out + assert "Score Distribution" not in captured.out + assert "" in captured.out + + def test_quiet_suppresses_stdin_banners( + self, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch + ) -> None: + """Test that --quiet suppresses banners for stdin analysis.""" + monkeypatch.setattr("sys.stdin", MagicMock(read=MagicMock(return_value="line 1\n"))) + + analyzer = MagicMock() + mock_result = MagicMock() + mock_result.output = "" + mock_result.merged_blocks = 0 + analyzer.analyze_text_detailed.return_value = mock_result + + analyze_stdin(analyzer, detailed=True, quiet=True) + captured = capsys.readouterr() + assert "Analyzing:" not in captured.out + assert "=" * 80 not in captured.out + assert "" in captured.out + + +class TestNewFlags: + """Tests for --fail-if-anomalies, --max-blocks, and --min-score flags.""" + + def test_fail_if_anomalies_flag(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Test that --fail-if-anomalies is parsed correctly.""" + monkeypatch.setattr(sys, "argv", ["cordon", "--fail-if-anomalies", "test.log"]) + args = parse_args() + assert args.fail_if_anomalies is True + + def test_fail_if_anomalies_default(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Test that --fail-if-anomalies defaults to False.""" + monkeypatch.setattr(sys, "argv", ["cordon", "test.log"]) + args = parse_args() + assert args.fail_if_anomalies is False + + def test_max_blocks_flag(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Test that --max-blocks is parsed correctly.""" + monkeypatch.setattr(sys, "argv", ["cordon", "--max-blocks", "10", "test.log"]) + args = parse_args() + assert args.max_blocks == 10 + + def test_max_blocks_default(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Test that --max-blocks defaults to None.""" + monkeypatch.setattr(sys, "argv", ["cordon", "test.log"]) + args = parse_args() + assert args.max_blocks is None + + def test_min_score_flag(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Test that --min-score is parsed correctly.""" + monkeypatch.setattr(sys, "argv", ["cordon", "--min-score", "0.5", "test.log"]) + args = parse_args() + assert args.min_score == 0.5 + + def test_min_score_default(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Test that --min-score defaults to None.""" + monkeypatch.setattr(sys, "argv", ["cordon", "test.log"]) + args = parse_args() + assert args.min_score is None + + def test_analyze_file_returns_true_when_anomalies(self, tmp_path: Path) -> None: + """Test that analyze_file returns True when anomalies are found.""" + log_file = tmp_path / "test.log" + log_file.write_text("line 1\n") + + analyzer = MagicMock() + mock_result = MagicMock() + mock_result.output = "block" + mock_result.merged_blocks = 3 + analyzer.analyze_file_detailed.return_value = mock_result + + result = analyze_file(log_file, analyzer, detailed=False, quiet=True) + assert result is True + + def test_analyze_file_returns_false_when_no_anomalies(self, tmp_path: Path) -> None: + """Test that analyze_file returns False when no anomalies are found.""" + log_file = tmp_path / "test.log" + log_file.write_text("line 1\n") + + analyzer = MagicMock() + mock_result = MagicMock() + mock_result.output = "" + mock_result.merged_blocks = 0 + analyzer.analyze_file_detailed.return_value = mock_result + + result = analyze_file(log_file, analyzer, detailed=False, quiet=True) + assert result is False + + class TestMainEntryPoint: """Tests for the main() entry point.""" diff --git a/tests/test_core.py b/tests/test_core.py index d33bc12..0b9cad7 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -187,6 +187,28 @@ def test_token_budget_validation(self) -> None: config = AnalysisConfig(token_budget=1000) assert config.token_budget == 1000 + def test_max_blocks_validation(self) -> None: + """Test that invalid max_blocks values are rejected.""" + with pytest.raises(ValueError, match="max_blocks"): + AnalysisConfig(max_blocks=0) + with pytest.raises(ValueError, match="max_blocks"): + AnalysisConfig(max_blocks=-1) + config = AnalysisConfig(max_blocks=5) + assert config.max_blocks == 5 + config_none = AnalysisConfig(max_blocks=None) + assert config_none.max_blocks is None + + def test_min_score_validation(self) -> None: + """Test that invalid min_score values are rejected.""" + with pytest.raises(ValueError, match="min_score"): + AnalysisConfig(min_score=-0.1) + config = AnalysisConfig(min_score=0.0) + assert config.min_score == 0.0 + config_pos = AnalysisConfig(min_score=0.5) + assert config_pos.min_score == 0.5 + config_none = AnalysisConfig(min_score=None) + assert config_none.min_score is None + class TestAnalysisResult: """Tests for AnalysisResult dataclass.""" diff --git a/tests/test_pipeline_unit.py b/tests/test_pipeline_unit.py index e53a207..f0272e0 100644 --- a/tests/test_pipeline_unit.py +++ b/tests/test_pipeline_unit.py @@ -4,6 +4,7 @@ from unittest.mock import MagicMock, patch from cordon.core.config import AnalysisConfig +from cordon.core.types import MergedBlock, ScoredWindow, TextWindow from cordon.pipeline import SemanticLogAnalyzer @@ -224,3 +225,119 @@ def test_token_budget_empty_input(self, mock_create: MagicMock) -> None: analyzer = SemanticLogAnalyzer(config) result = analyzer.analyze_text_detailed("") assert result.total_lines == 0 + + +class TestPostMergeFiltering: + """Tests for max_blocks and min_score post-merge filtering.""" + + @patch("cordon.pipeline.create_embedder") + def test_max_blocks_filtering(self, mock_create: MagicMock) -> None: + """Test that max_blocks keeps only the top N highest-scoring blocks.""" + mock_embedder = MagicMock() + mock_create.return_value = mock_embedder + mock_embedder.embed_windows.return_value = iter([]) + + mock_reader = MagicMock() + mock_reader.read_lines.return_value = iter([(i, f"line {i}") for i in range(1, 21)]) + + blocks = [ + MergedBlock(start_line=1, end_line=3, original_windows=(0,), max_score=0.3), + MergedBlock(start_line=5, end_line=7, original_windows=(1,), max_score=0.9), + MergedBlock(start_line=10, end_line=12, original_windows=(2,), max_score=0.6), + MergedBlock(start_line=15, end_line=17, original_windows=(3,), max_score=0.8), + ] + mock_merger = MagicMock() + mock_merger.merge_windows.return_value = blocks + + mock_thresholder = MagicMock() + window = TextWindow(content="test", start_line=1, end_line=3, window_id=0) + mock_thresholder.select_significant.return_value = [ScoredWindow(window=window, score=0.5)] + + config = AnalysisConfig(device="cpu", max_blocks=2) + analyzer = SemanticLogAnalyzer( + config, + reader=mock_reader, + merger=mock_merger, + thresholder=mock_thresholder, + ) + result = analyzer.analyze_file_detailed(Path("dummy.log")) + + assert result.merged_blocks == 2 + assert len(result.blocks) == 2 + scores = [b.max_score for b in result.blocks] + assert 0.9 in scores + assert 0.8 in scores + assert result.blocks[0].start_line < result.blocks[1].start_line + + @patch("cordon.pipeline.create_embedder") + def test_min_score_filtering(self, mock_create: MagicMock) -> None: + """Test that min_score drops blocks below the threshold.""" + mock_embedder = MagicMock() + mock_create.return_value = mock_embedder + mock_embedder.embed_windows.return_value = iter([]) + + mock_reader = MagicMock() + mock_reader.read_lines.return_value = iter([(i, f"line {i}") for i in range(1, 21)]) + + blocks = [ + MergedBlock(start_line=1, end_line=3, original_windows=(0,), max_score=0.2), + MergedBlock(start_line=5, end_line=7, original_windows=(1,), max_score=0.5), + MergedBlock(start_line=10, end_line=12, original_windows=(2,), max_score=0.8), + ] + mock_merger = MagicMock() + mock_merger.merge_windows.return_value = blocks + + mock_thresholder = MagicMock() + window = TextWindow(content="test", start_line=1, end_line=3, window_id=0) + mock_thresholder.select_significant.return_value = [ScoredWindow(window=window, score=0.5)] + + config = AnalysisConfig(device="cpu", min_score=0.5) + analyzer = SemanticLogAnalyzer( + config, + reader=mock_reader, + merger=mock_merger, + thresholder=mock_thresholder, + ) + result = analyzer.analyze_file_detailed(Path("dummy.log")) + + assert result.merged_blocks == 2 + assert len(result.blocks) == 2 + assert all(b.max_score >= 0.5 for b in result.blocks) + + @patch("cordon.pipeline.create_embedder") + def test_max_blocks_and_min_score_combined(self, mock_create: MagicMock) -> None: + """Test that min_score is applied before max_blocks.""" + mock_embedder = MagicMock() + mock_create.return_value = mock_embedder + mock_embedder.embed_windows.return_value = iter([]) + + mock_reader = MagicMock() + mock_reader.read_lines.return_value = iter([(i, f"line {i}") for i in range(1, 21)]) + + blocks = [ + MergedBlock(start_line=1, end_line=3, original_windows=(0,), max_score=0.2), + MergedBlock(start_line=5, end_line=7, original_windows=(1,), max_score=0.5), + MergedBlock(start_line=10, end_line=12, original_windows=(2,), max_score=0.7), + MergedBlock(start_line=15, end_line=17, original_windows=(3,), max_score=0.9), + ] + mock_merger = MagicMock() + mock_merger.merge_windows.return_value = blocks + + mock_thresholder = MagicMock() + window = TextWindow(content="test", start_line=1, end_line=3, window_id=0) + mock_thresholder.select_significant.return_value = [ScoredWindow(window=window, score=0.5)] + + config = AnalysisConfig(device="cpu", min_score=0.5, max_blocks=2) + analyzer = SemanticLogAnalyzer( + config, + reader=mock_reader, + merger=mock_merger, + thresholder=mock_thresholder, + ) + result = analyzer.analyze_file_detailed(Path("dummy.log")) + + assert result.merged_blocks == 2 + assert len(result.blocks) == 2 + scores = [b.max_score for b in result.blocks] + assert 0.9 in scores + assert 0.7 in scores From 780e708d930acf101e6cf1d505d60b6b4cfa8819 Mon Sep 17 00:00:00 2001 From: Caleb Evans Date: Fri, 22 May 2026 17:27:45 -0600 Subject: [PATCH 3/3] refactor: remove --fail-if-anomalies flag The tool inherently always finds anomalies, making this flag impractical for CI gating. Remove the flag, anomaly tracking logic, and associated tests. Co-authored-by: Cursor --- src/cordon/cli.py | 38 +++++++------------------------------- tests/test_cli.py | 42 +----------------------------------------- 2 files changed, 8 insertions(+), 72 deletions(-) diff --git a/src/cordon/cli.py b/src/cordon/cli.py index 511cee8..a05cde2 100644 --- a/src/cordon/cli.py +++ b/src/cordon/cli.py @@ -186,12 +186,6 @@ def parse_args() -> argparse.Namespace: action="store_true", help="Suppress all human-readable banners and progress bars, keeping only formatted output on stdout", ) - output_group.add_argument( - "--fail-if-anomalies", - action="store_true", - help="Exit with code 2 if anomalies are found (useful for CI gating)", - ) - return parser.parse_args() @@ -285,7 +279,7 @@ def analyze_file( output_path: Path | None = None, force: bool = False, quiet: bool = False, -) -> bool: +) -> None: """Analyze a single log file and print results. Args: @@ -295,12 +289,9 @@ def analyze_file( output_path: Optional path to save anomalous blocks (None prints to stdout). force: If True, overwrite an existing output file. quiet: If True, suppress human-readable banners and stats. - - Returns: - True if anomalies were found, False otherwise. """ if not _validate_file(log_path): - return False + return if not quiet: print("=" * 80) @@ -311,12 +302,10 @@ def analyze_file( result = analyzer.analyze_file_detailed(log_path) except Exception as error: print(f"Error analyzing {log_path}: {error}", file=sys.stderr) - return False + return _display_results(result, detailed, output_path, force, quiet) - return result.merged_blocks > 0 - def analyze_stdin( analyzer: SemanticLogAnalyzer, @@ -324,7 +313,7 @@ def analyze_stdin( output_path: Path | None = None, force: bool = False, quiet: bool = False, -) -> bool: +) -> None: """Analyze log data from stdin and print results. Args: @@ -333,9 +322,6 @@ def analyze_stdin( output_path: Optional path to save anomalous blocks (None prints to stdout). force: If True, overwrite an existing output file. quiet: If True, suppress human-readable banners and stats. - - Returns: - True if anomalies were found, False otherwise. """ text = sys.stdin.read() @@ -348,12 +334,10 @@ def analyze_stdin( result = analyzer.analyze_text_detailed(text) except Exception as error: print(f"Error analyzing : {error}", file=sys.stderr) - return False + return _display_results(result, detailed, output_path, force, quiet) - return result.merged_blocks > 0 - def _print_backend_info(config: AnalysisConfig) -> None: """Print backend configuration details.""" @@ -462,21 +446,13 @@ def _main_impl() -> None: print() # analyze each log file - any_anomalies_found = False for log_path in args.logfiles: if str(log_path) == "-": - found = analyze_stdin( - analyzer, args.detailed, args.output, args.force, quiet=args.quiet - ) + analyze_stdin(analyzer, args.detailed, args.output, args.force, quiet=args.quiet) else: - found = analyze_file( + analyze_file( log_path, analyzer, args.detailed, args.output, args.force, quiet=args.quiet ) - if found: - any_anomalies_found = True - - if args.fail_if_anomalies and any_anomalies_found: - sys.exit(2) def main() -> None: diff --git a/tests/test_cli.py b/tests/test_cli.py index 8e94c62..59b06ff 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -329,19 +329,7 @@ def test_quiet_suppresses_stdin_banners( class TestNewFlags: - """Tests for --fail-if-anomalies, --max-blocks, and --min-score flags.""" - - def test_fail_if_anomalies_flag(self, monkeypatch: pytest.MonkeyPatch) -> None: - """Test that --fail-if-anomalies is parsed correctly.""" - monkeypatch.setattr(sys, "argv", ["cordon", "--fail-if-anomalies", "test.log"]) - args = parse_args() - assert args.fail_if_anomalies is True - - def test_fail_if_anomalies_default(self, monkeypatch: pytest.MonkeyPatch) -> None: - """Test that --fail-if-anomalies defaults to False.""" - monkeypatch.setattr(sys, "argv", ["cordon", "test.log"]) - args = parse_args() - assert args.fail_if_anomalies is False + """Tests for --max-blocks and --min-score flags.""" def test_max_blocks_flag(self, monkeypatch: pytest.MonkeyPatch) -> None: """Test that --max-blocks is parsed correctly.""" @@ -367,34 +355,6 @@ def test_min_score_default(self, monkeypatch: pytest.MonkeyPatch) -> None: args = parse_args() assert args.min_score is None - def test_analyze_file_returns_true_when_anomalies(self, tmp_path: Path) -> None: - """Test that analyze_file returns True when anomalies are found.""" - log_file = tmp_path / "test.log" - log_file.write_text("line 1\n") - - analyzer = MagicMock() - mock_result = MagicMock() - mock_result.output = "block" - mock_result.merged_blocks = 3 - analyzer.analyze_file_detailed.return_value = mock_result - - result = analyze_file(log_file, analyzer, detailed=False, quiet=True) - assert result is True - - def test_analyze_file_returns_false_when_no_anomalies(self, tmp_path: Path) -> None: - """Test that analyze_file returns False when no anomalies are found.""" - log_file = tmp_path / "test.log" - log_file.write_text("line 1\n") - - analyzer = MagicMock() - mock_result = MagicMock() - mock_result.output = "" - mock_result.merged_blocks = 0 - analyzer.analyze_file_detailed.return_value = mock_result - - result = analyze_file(log_file, analyzer, detailed=False, quiet=True) - assert result is False - class TestMainEntryPoint: """Tests for the main() entry point."""