2026-09-08 12:23:40 +02:00
|
|
|
"""The evidence ladder: what established a finding (model-grounded-detection Req 5).
|
|
|
|
|
|
|
|
|
|
Every finding carries the rung that established it, so a deterministic model result is never confused with an
|
|
|
|
|
LLM suspicion. The two middle rungs are the point of this feature:
|
|
|
|
|
|
|
|
|
|
suspicion a site was enumerated and something flagged it
|
|
|
|
|
model_corroborated the LLM's claim is consistent with the CPG -- the source it names reaches the sink it
|
|
|
|
|
names, or a guard its siblings carry is absent
|
|
|
|
|
model_entailed the CPG establishes it alone, independent of trusting the LLM
|
|
|
|
|
execution_confirmed a sandbox reproduced it (deferred tier, memory/C first)
|
|
|
|
|
|
|
|
|
|
`model_corroborated` and above are established with no model call, and deterministically: run the checker
|
|
|
|
|
twice over the same CPG and the verdict sequence is identical. That is what removes the run-to-run noise the
|
|
|
|
|
prior architecture tried to average away.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
from dataclasses import dataclass
|
|
|
|
|
from enum import StrEnum
|
2026-09-08 13:49:55 +02:00
|
|
|
from typing import TYPE_CHECKING, TypeVar
|
|
|
|
|
|
|
|
|
|
if TYPE_CHECKING:
|
|
|
|
|
from ..findings import StaticFinding
|
|
|
|
|
|
|
|
|
|
_Finding = TypeVar("_Finding", bound="StaticFinding")
|
2026-09-08 12:23:40 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class Rung(StrEnum):
|
|
|
|
|
SUSPICION = "suspicion"
|
|
|
|
|
CORROBORATED = "model_corroborated"
|
|
|
|
|
ENTAILED = "model_entailed"
|
|
|
|
|
EXECUTION_CONFIRMED = "execution_confirmed"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Ascending strength; `_ORDER.index` is the comparison, so a new rung cannot be silently mis-ranked.
|
|
|
|
|
_ORDER = (Rung.SUSPICION, Rung.CORROBORATED, Rung.ENTAILED, Rung.EXECUTION_CONFIRMED)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def at_or_above(rung: Rung, threshold: Rung) -> bool:
|
|
|
|
|
return _ORDER.index(rung) >= _ORDER.index(threshold)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
class Verdict:
|
|
|
|
|
"""What the model established, and the evidence it established it from."""
|
|
|
|
|
|
|
|
|
|
rung: Rung
|
|
|
|
|
family: str
|
|
|
|
|
witness: str = "" # the taint path or the dominance fact; empty only at `suspicion`
|
|
|
|
|
contradiction: str = "" # why an LLM claim was dropped, when it was
|
2026-09-09 09:27:19 +02:00
|
|
|
# ``path:line`` of the evidence, when the arbiter knows it. Once a flow may cross into another module,
|
|
|
|
|
# the region that asked the question is no longer where the answer lives, and a finding reported against
|
|
|
|
|
# the handler's file when the bug is two modules away is unactionable.
|
|
|
|
|
location: str = ""
|
2026-09-08 12:23:40 +02:00
|
|
|
|
|
|
|
|
|
2026-09-08 13:49:55 +02:00
|
|
|
def at_rung(finding: _Finding, verdict: Verdict | None) -> _Finding:
|
|
|
|
|
"""Return ``finding`` carrying ``verdict``'s rung and witness, or unchanged when there is no verdict.
|
|
|
|
|
|
|
|
|
|
This is the only way a finding rises above ``suspicion`` (Req 5.4). The witness travels with the rung
|
|
|
|
|
because a rung without the evidence that justifies it is just a louder assertion.
|
|
|
|
|
"""
|
|
|
|
|
from dataclasses import replace
|
|
|
|
|
|
|
|
|
|
if verdict is None or verdict.rung is Rung.SUSPICION:
|
|
|
|
|
return finding
|
|
|
|
|
rationale = finding.rationale
|
|
|
|
|
if verdict.witness and verdict.witness not in rationale:
|
|
|
|
|
rationale = f"{rationale} [{verdict.rung.value}: {verdict.witness}]".strip()
|
|
|
|
|
return replace(finding, rung=verdict.rung.value, rationale=rationale)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
__all__ = ["Rung", "Verdict", "at_or_above", "at_rung"]
|