From 262cf4326b60400444a9d1a1a08e0076f2471436 Mon Sep 17 00:00:00 2001 From: Corey Leath Date: Sun, 9 Aug 2026 17:15:09 -0400 Subject: [PATCH 1/8] feat(reasoning): classify explanation support --- logsight/reasoning.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/logsight/reasoning.py b/logsight/reasoning.py index d6bfdd9..cb3fe4b 100644 --- a/logsight/reasoning.py +++ b/logsight/reasoning.py @@ -12,6 +12,7 @@ from logsight.analyzer import AnomalyReport, ErrorRateSpike EvidenceStrength = Literal["direct", "statistical"] +SupportLevel = Literal["single-signal", "corroborated"] @dataclass(frozen=True) @@ -21,6 +22,7 @@ class EvidenceExplanation: category: str summary: str evidence_strength: EvidenceStrength + support_level: SupportLevel evidence: tuple[str, ...] @@ -42,6 +44,9 @@ def explain_report( "no root cause is inferred." ), evidence_strength="direct", + support_level=( + "corroborated" if "message_length_zscore" in finding.reasons else "single-signal" + ), evidence=( f"level={entry.level.value}", f"message_length={len(entry.message)}", @@ -57,6 +62,9 @@ def explain_report( "statistical threshold; no root cause is inferred." ), evidence_strength="statistical", + support_level=( + "corroborated" if "error_level" in finding.reasons else "single-signal" + ), evidence=( f"zscore={finding.message_length_zscore:.3f}", f"threshold={report.zscore_threshold:.3f}", @@ -74,6 +82,7 @@ def explain_report( "threshold; no root cause is inferred." ), evidence_strength="statistical", + support_level="single-signal", evidence=( f"errors={spike.error_count}", f"entries={spike.total}", From b4bc42cf872341aba2b65de022802db168b9a2b3 Mon Sep 17 00:00:00 2001 From: Corey Leath Date: Sun, 9 Aug 2026 17:15:12 -0400 Subject: [PATCH 2/8] feat(cli): display explanation support --- logsight/cli.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/logsight/cli.py b/logsight/cli.py index 2f4d62b..076cb55 100644 --- a/logsight/cli.py +++ b/logsight/cli.py @@ -56,8 +56,8 @@ def _print_explanations(explanations: list[EvidenceExplanation]) -> None: for explanation in explanations: evidence = ", ".join(explanation.evidence) console.print( - f" [{explanation.evidence_strength}] {escape(explanation.summary)} " - f"([dim]{escape(evidence)}[/dim])" + f" [{explanation.evidence_strength}; {explanation.support_level}] " + f"{escape(explanation.summary)} ([dim]{escape(evidence)}[/dim])" ) From d54d9b14b2f79cf59afd6267a5f29b1a5aed6276 Mon Sep 17 00:00:00 2001 From: Corey Leath Date: Sun, 9 Aug 2026 17:15:14 -0400 Subject: [PATCH 3/8] test(reasoning): cover corroborated support --- tests/test_reasoning.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/test_reasoning.py b/tests/test_reasoning.py index d1e6990..52f72ab 100644 --- a/tests/test_reasoning.py +++ b/tests/test_reasoning.py @@ -19,6 +19,7 @@ def test_explains_direct_error_evidence_without_causal_claim(): assert len(explanations) == 1 assert explanations[0].category == "error-level" assert explanations[0].evidence_strength == "direct" + assert explanations[0].support_level == "single-signal" assert "level=ERROR" in explanations[0].evidence assert "no root cause is inferred" in explanations[0].summary @@ -48,3 +49,12 @@ def test_returns_no_explanations_when_detector_has_no_findings(): report = detect_anomalies([_entry("INFO", "healthy")]) assert explain_report(report) == [] + + +def test_marks_two_detector_signals_as_corroborated(): + entries = [_entry("INFO", "normal")] * 20 + [_entry("ERROR", "x" * 500)] + report = detect_anomalies(entries, zscore_threshold=2.0) + + explanations = explain_report(report) + + assert any(item.support_level == "corroborated" for item in explanations) From 20f7abb340ddee835500e911b148342ee9605f53 Mon Sep 17 00:00:00 2001 From: Corey Leath Date: Sun, 9 Aug 2026 17:15:16 -0400 Subject: [PATCH 4/8] test(cli): cover support output --- tests/test_cli.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_cli.py b/tests/test_cli.py index f37225e..16c742b 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -68,4 +68,5 @@ def test_explain_uses_detector_evidence(self, tmp_path): assert result.exit_code == 0 assert "Evidence-backed findings" in result.output assert "level=ERROR" in result.output + assert "single-signal" in result.output assert "no root cause is inferred" in " ".join(result.output.split()) From 3f3ac1fd514a681b5a9c7b3371cb0051b980c67f Mon Sep 17 00:00:00 2001 From: Corey Leath Date: Sun, 9 Aug 2026 17:15:18 -0400 Subject: [PATCH 5/8] docs: define explanation support levels --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a4ac487..aa9b567 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ Supported formats include ISO-8601 application logs, syslog, nginx access logs, ## Evidence-backed reasoning -The optional `--explain` flag turns existing detector output into concise, user-facing evidence statements. Each statement identifies its direct or statistical basis: parsed error level, message-length z-score with its configured threshold, or observed error count/rate in a complete analysis window. LogSight does not infer an incident root cause, use an LLM, send logs externally, or report a model-confidence score. +The optional `--explain` flag turns existing detector output into concise, user-facing evidence statements. Each statement identifies its direct or statistical basis: parsed error level, message-length z-score with its configured threshold, or observed error count/rate in a complete analysis window. Each statement also reports a deterministic support level: `single-signal` for one detector signal and `corroborated` when the same entry meets both error-level and statistical criteria. LogSight does not infer an incident root cause, use an LLM, send logs externally, or report a model-confidence score. ```bash logsight analyze application.log --window 200 --spike-threshold 0.20 --explain From d4e90790fa815e36e57490b5c5e667663f09af6d Mon Sep 17 00:00:00 2001 From: Corey Leath Date: Sun, 9 Aug 2026 17:18:45 -0400 Subject: [PATCH 6/8] style(reasoning): format support classification --- logsight/reasoning.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/logsight/reasoning.py b/logsight/reasoning.py index cb3fe4b..8964485 100644 --- a/logsight/reasoning.py +++ b/logsight/reasoning.py @@ -45,7 +45,9 @@ def explain_report( ), evidence_strength="direct", support_level=( - "corroborated" if "message_length_zscore" in finding.reasons else "single-signal" + "corroborated" + if "message_length_zscore" in finding.reasons + else "single-signal" ), evidence=( f"level={entry.level.value}", @@ -63,7 +65,9 @@ def explain_report( ), evidence_strength="statistical", support_level=( - "corroborated" if "error_level" in finding.reasons else "single-signal" + "corroborated" + if "error_level" in finding.reasons + else "single-signal" ), evidence=( f"zscore={finding.message_length_zscore:.3f}", From 76880713bf2c508311d953ffc58a3b51080f6dd6 Mon Sep 17 00:00:00 2001 From: Corey Leath Date: Sun, 9 Aug 2026 17:19:56 -0400 Subject: [PATCH 7/8] style(reasoning): apply ruff formatting --- logsight/reasoning.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/logsight/reasoning.py b/logsight/reasoning.py index 8964485..aa19c07 100644 --- a/logsight/reasoning.py +++ b/logsight/reasoning.py @@ -65,9 +65,7 @@ def explain_report( ), evidence_strength="statistical", support_level=( - "corroborated" - if "error_level" in finding.reasons - else "single-signal" + "corroborated" if "error_level" in finding.reasons else "single-signal" ), evidence=( f"zscore={finding.message_length_zscore:.3f}", From da8e7ee940df39d1306109209d9a07515d5698fb Mon Sep 17 00:00:00 2001 From: Corey Leath Date: Sun, 9 Aug 2026 17:21:42 -0400 Subject: [PATCH 8/8] fix(cli): render explanation support as plain text --- logsight/cli.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/logsight/cli.py b/logsight/cli.py index 076cb55..3db38c8 100644 --- a/logsight/cli.py +++ b/logsight/cli.py @@ -56,8 +56,8 @@ def _print_explanations(explanations: list[EvidenceExplanation]) -> None: for explanation in explanations: evidence = ", ".join(explanation.evidence) console.print( - f" [{explanation.evidence_strength}; {explanation.support_level}] " - f"{escape(explanation.summary)} ([dim]{escape(evidence)}[/dim])" + f" support={explanation.evidence_strength};{explanation.support_level} — " + f"{escape(explanation.summary)} ({escape(evidence)})" )