Explain score contributions

ober

5cd6532f70102d9d68ac6ec3093b70a5377932c8

diff --git a/README.md b/README.md
index 73b895c..5fb8071 100644
--- a/README.md
+++ b/README.md
@@ -63,6 +63,10 @@ and `network_used:false`.
 counts, metadata/heuristic counts, score-band counts, and signal-category
 coverage for the selected revision range.
 
+`explain` prints the selected commit's thresholds, raw signals, weighted signal
+contributions, corroboration bonus, counter-evidence from zero-score signals,
+and warnings.
+
 Merge commits default to first-parent diffs. Use `--all-parents` to inspect
 per-parent merge diffs via Git's `-m` mode; merge warnings state which mode was
 used.
diff --git a/main-binary.ss b/main-binary.ss
index b3740aa..0001797 100644
--- a/main-binary.ss
+++ b/main-binary.ss
@@ -729,18 +729,44 @@
     (displayln (str "| `" (short-sha (finding-commit f)) "` | " (finding-score f) " | "
                     (finding-verdict f) " | " (finding-author-email f) " | " (finding-subject f) " |"))))
 
+(def (signal-contribution s)
+  (* (signal-score s) (signal-weight s)))
+
+(def (corroboration-bonus signals score)
+  (max 0.0 (- score (weighted-score signals))))
+
+(def (threshold-description score)
+  (cond [(>= score (scan-config-ai-threshold current-config)) "ai threshold met"]
+        [(>= score (scan-config-human-threshold current-config)) "uncertain threshold met"]
+        [else "below heuristic thresholds"]))
+
+(def (zero-score-signals signals)
+  (filter (lambda (s) (= (signal-score s) 0.0)) signals))
 (def (display-explain f)
   (displayln (str "commit: " (finding-commit f)))
   (displayln (str "verdict: " (finding-verdict f)))
   (displayln (str "score: " (finding-score f)))
   (displayln (str "author: " (finding-author-name f) " <" (finding-author-email f) ">"))
   (displayln (str "subject: " (finding-subject f)))
+  (displayln (str "thresholds: human=" (scan-config-human-threshold current-config)
+                  " ai=" (scan-config-ai-threshold current-config)
+                  " result=" (threshold-description (finding-score f))))
   (displayln "signals:")
   (for ([s (finding-signals f)])
     (displayln (str "- " (signal-name s) " [" (signal-category s) "] score=" (signal-score s)
                     " weight=" (signal-weight s)))
     (for ([e (signal-evidence s)])
       (displayln (str "  evidence: " e))))
+  (displayln "contributions:")
+  (for ([s (finding-signals f)])
+    (displayln (str "- " (signal-name s) ": " (signal-contribution s))))
+  (displayln (str "corroboration bonus: " (corroboration-bonus (finding-signals f) (finding-score f))))
+  (let ([zeros (zero-score-signals (finding-signals f))])
+    (if (pair? zeros)
+        (begin
+          (displayln "counter-evidence:")
+          (for ([s zeros])
+            (displayln (str "- no positive " (signal-category s) " evidence from " (signal-name s)))))))
   (if (pair? (finding-warnings f))
       (begin
         (displayln "warnings:")
diff --git a/tests/fixture-smoke.sh b/tests/fixture-smoke.sh
index 5f55a44..9aa0fdc 100755
--- a/tests/fixture-smoke.sh
+++ b/tests/fixture-smoke.sh
@@ -109,6 +109,10 @@ printf '%s\n' "$root_json" | grep -q 'root commit; no parent diff baseline'
 explain=$("$root/bin/jerboa-aigit" explain "$head_rev" "$fixture")
 printf '%s\n' "$explain" | grep -q "commit: $head_rev"
 printf '%s\n' "$explain" | grep -q 'signals:'
+printf '%s\n' "$explain" | grep -q 'thresholds:'
+printf '%s\n' "$explain" | grep -q 'contributions:'
+printf '%s\n' "$explain" | grep -q 'corroboration bonus:'
+printf '%s\n' "$explain" | grep -q 'counter-evidence:'
 
 markdown=$("$root/bin/jerboa-aigit" scan "$fixture" --format markdown --count 1)
 printf '%s\n' "$markdown" | grep -q '| commit | score | verdict | author | subject |'