diff --git a/adiuvare/core/scorer.py b/adiuvare/core/scorer.py index 70304c5..3b80040 100644 --- a/adiuvare/core/scorer.py +++ b/adiuvare/core/scorer.py @@ -2,12 +2,16 @@ _weights = { "payload": 0.40, - "behavior": 0.35, - "identity": 0.25, + "behavior": 0.30, + "identity": 0.15, "context": 0.10, "ip_rep": 0.05, } +_total_w = sum(_weights.values()) +assert _total_w > 0, "default _weights must sum to a positive value" +_weights = {k: v / _total_w for k, v in _weights.items()} + def compute_score(sig_res: dict[str, SignalResult], snap=None) -> tuple[float, dict[str, float]]: breakdown: dict[str, float] = {} @@ -16,9 +20,25 @@ def compute_score(sig_res: dict[str, SignalResult], snap=None) -> tuple[float, d weights = dict(_weights) if snap: - weights["payload"] = snap.payload_weight - weights["behavior"] = snap.behavior_weight - weights["identity"] = snap.identity_weight + snap_weights = { + "payload": snap.payload_weight, + "behavior": snap.behavior_weight, + "identity": snap.identity_weight, + } + + # only snap-overridden keys are validated; context and ip_rep carry + # over from _weights which are already normalized and guaranteed positive + for k, v in snap_weights.items(): + if v < 0: + raise ValueError(f"Weight for '{k}' must be non-negative, got {v}") + + total_snap = sum(snap_weights.values()) + if total_snap <= 0: + raise ValueError("Snap weights sum to zero or below - cannot normalize.") + + weights.update(snap_weights) + total_w = sum(weights.values()) + weights = {k: v / total_w for k, v in weights.items()} for name, res in sig_res.items(): weight = weights.get(name, 0.0) diff --git a/tests/test_command_probe.py b/tests/test_command_probe.py index 43f0ce4..eeb4c10 100644 --- a/tests/test_command_probe.py +++ b/tests/test_command_probe.py @@ -11,7 +11,7 @@ def test_command_probe_semicolon_detected(): assert gate.passed is True assert event is not None - assert event.score > 0.35 + assert event.score > 0.30 assert "payload" in event.breakdown assert event.breakdown["payload"] > 0.0 @@ -26,7 +26,7 @@ def test_command_probe_dollar_detected(): assert gate.passed is True assert event is not None - assert event.score > 0.35 + assert event.score > 0.30 assert "payload" in event.breakdown assert event.breakdown["payload"] > 0.0 diff --git a/tests/test_scorer.py b/tests/test_scorer.py index fe634c6..7b13f57 100644 --- a/tests/test_scorer.py +++ b/tests/test_scorer.py @@ -1,3 +1,4 @@ +import pytest from adiuvare.core.models import ConfigSnapshot, SignalResult from adiuvare.core.scorer import compute_score from adiuvare.core.verdict import compute_verdict @@ -11,8 +12,8 @@ def test_score_uses_hardcoded_weights(): } ) - assert round(score, 3) == 0.395 - assert round(breakdown["payload"], 3) == 0.28 + assert score == pytest.approx(0.38, rel=1e-3) + assert breakdown["payload"] == pytest.approx(0.28, rel=1e-3) def test_verdict_maps_score_ranges(): @@ -39,8 +40,7 @@ def test_score_can_use_snapshot_weights(): snap, ) - assert round(score, 3) == 0.45 - assert round(breakdown["payload"], 3) == 0.35 + assert score == pytest.approx(0.393, rel=1e-3) def test_verdict_gets_identity_nudge_inline(): @@ -53,3 +53,29 @@ def test_verdict_gets_identity_nudge_inline(): block_threshold=0.80, ) assert compute_verdict(0.50, snap, identity_risk=0.70) == "throttle" + +def test_identity_heavy_detection(): + score, breakdown = compute_score( + { + "identity": SignalResult(score=1.0, reason="known_bad_actor"), + "ip_rep": SignalResult(score=0.8, reason="blacklisted_ip"), + } + ) + assert score == pytest.approx(0.20, rel=1e-3) + assert breakdown["identity"] == pytest.approx(0.15, rel=1e-3) + +def test_negative_weight_raises(): + snap = ConfigSnapshot( + payload_weight=-0.5, behavior_weight=0.5, identity_weight=0.0, + flag_threshold=0.25, throttle_threshold=0.55, block_threshold=0.80, + ) + with pytest.raises(ValueError, match="non-negative"): + compute_score({"payload": SignalResult(score=0.7, reason="sql_hit")}, snap) + +def test_all_zero_weights_raises(): + snap = ConfigSnapshot( + payload_weight=0.0, behavior_weight=0.0, identity_weight=0.0, + flag_threshold=0.25, throttle_threshold=0.55, block_threshold=0.80, + ) + with pytest.raises(ValueError, match="sum to zero"): + compute_score({"payload": SignalResult(score=0.7, reason="sql_hit")}, snap) \ No newline at end of file