agentverity 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agentverity/__init__.py +53 -0
- agentverity/adapters/__init__.py +14 -0
- agentverity/adapters/callable_adapter.py +43 -0
- agentverity/adapters/strands.py +100 -0
- agentverity/blindness.py +164 -0
- agentverity/cli.py +113 -0
- agentverity/meter.py +204 -0
- agentverity/observation.py +64 -0
- agentverity/py.typed +0 -0
- agentverity/relations.py +142 -0
- agentverity/runner.py +376 -0
- agentverity-0.2.0.dist-info/METADATA +401 -0
- agentverity-0.2.0.dist-info/RECORD +16 -0
- agentverity-0.2.0.dist-info/WHEEL +4 -0
- agentverity-0.2.0.dist-info/entry_points.txt +2 -0
- agentverity-0.2.0.dist-info/licenses/LICENSE +202 -0
agentverity/__init__.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""agentverity — measure-first testing for non-deterministic LLM agents.
|
|
2
|
+
|
|
3
|
+
Before trusting any test suite, agentverity tells you whether your agent's
|
|
4
|
+
verdict is stable enough to test against, and whether a passing relation is
|
|
5
|
+
trivially satisfied by an indifferent agent. Two headline diagnostics:
|
|
6
|
+
|
|
7
|
+
1. **Verdict-stochasticity meter** — does the agent's decision flip across
|
|
8
|
+
identical reruns? If not, a frozen-output diff dominates and metamorphic
|
|
9
|
+
relations add little.
|
|
10
|
+
2. **Constant-gate-blindness detector** — does the agent return a near-constant
|
|
11
|
+
verdict across a diverse input set? If so, every relation passes
|
|
12
|
+
trivially and the suite is lying to you.
|
|
13
|
+
|
|
14
|
+
Metamorphic relations are the vehicle; the diagnostics are the product.
|
|
15
|
+
|
|
16
|
+
Quickstart::
|
|
17
|
+
|
|
18
|
+
from agentverity import run, from_callable
|
|
19
|
+
from agentverity.relations import builtin_relations
|
|
20
|
+
|
|
21
|
+
agent = from_callable(my_agent_fn)
|
|
22
|
+
result = run(agent, inputs=["hello", "world"], relations=builtin_relations())
|
|
23
|
+
print(result.summary())
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
27
|
+
|
|
28
|
+
from agentverity.adapters import from_callable
|
|
29
|
+
from agentverity.blindness import BlindnessResult, detect
|
|
30
|
+
from agentverity.meter import MeterResult, measure
|
|
31
|
+
from agentverity.observation import Observation
|
|
32
|
+
from agentverity.relations import Relation, builtin_relations
|
|
33
|
+
from agentverity.runner import RelationResult, RunConfig, RunResult, run
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"BlindnessResult",
|
|
37
|
+
"MeterResult",
|
|
38
|
+
"Observation",
|
|
39
|
+
"Relation",
|
|
40
|
+
"RelationResult",
|
|
41
|
+
"RunConfig",
|
|
42
|
+
"RunResult",
|
|
43
|
+
"builtin_relations",
|
|
44
|
+
"detect",
|
|
45
|
+
"from_callable",
|
|
46
|
+
"measure",
|
|
47
|
+
"run",
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
__version__ = version("agentverity")
|
|
52
|
+
except PackageNotFoundError:
|
|
53
|
+
__version__ = "0+unknown"
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Adapters turn a real agent into ``run(input) -> Observation``.
|
|
2
|
+
|
|
3
|
+
Only ``callable`` is imported here (zero deps). Library adapters (Strands,
|
|
4
|
+
LangGraph) are imported lazily from their own modules so the core installs
|
|
5
|
+
without those libraries present.
|
|
6
|
+
|
|
7
|
+
Available adapters:
|
|
8
|
+
- :func:`from_callable` — wrap any ``fn(input) -> str | dict | Observation``.
|
|
9
|
+
- :func:`from_strands` — wrap a Strands ``Agent`` (requires ``strands-agents``).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from agentverity.adapters.callable_adapter import from_callable
|
|
13
|
+
|
|
14
|
+
__all__ = ["from_callable"]
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Adapter for any bare callable agent: fn(input) -> str | dict | Observation.
|
|
2
|
+
|
|
3
|
+
The most general adapter, and the one used for non-library agents and tests.
|
|
4
|
+
Returns an AgentFn: (input:str) -> Observation.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from agentverity.observation import Observation
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def from_callable(fn: Callable[[str], Any], *,
|
|
16
|
+
verdict_key: str | None = None,
|
|
17
|
+
tools_key: str | None = None):
|
|
18
|
+
"""Wrap `fn` so it yields an Observation.
|
|
19
|
+
|
|
20
|
+
fn may return:
|
|
21
|
+
- a str -> becomes Observation.text
|
|
22
|
+
- an Observation -> passed through
|
|
23
|
+
- a dict -> read 'text'/'verdict'/'tools', or use verdict_key/
|
|
24
|
+
tools_key to name the fields to pull out.
|
|
25
|
+
"""
|
|
26
|
+
def run(x: str) -> Observation:
|
|
27
|
+
out = fn(x)
|
|
28
|
+
if isinstance(out, Observation):
|
|
29
|
+
return out
|
|
30
|
+
if isinstance(out, str):
|
|
31
|
+
return Observation(text=out)
|
|
32
|
+
if isinstance(out, dict):
|
|
33
|
+
text = str(out.get("text", ""))
|
|
34
|
+
verdict = out.get(verdict_key) if verdict_key else out.get("verdict")
|
|
35
|
+
raw_tools = out.get(tools_key) if tools_key else out.get("tools")
|
|
36
|
+
tools = tuple(raw_tools) if isinstance(raw_tools, (list, tuple)) else ()
|
|
37
|
+
return Observation(text=text,
|
|
38
|
+
verdict=(str(verdict) if verdict is not None else None),
|
|
39
|
+
tools=tools, raw=out)
|
|
40
|
+
# anything else: stringify into text, keep raw
|
|
41
|
+
return Observation(text=str(out), raw=out)
|
|
42
|
+
|
|
43
|
+
return run
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Strands adapter — wraps a Strands ``Agent`` into ``run(input) -> Observation``.
|
|
2
|
+
|
|
3
|
+
Strands is an AWS-backed agent framework (``strands-agents`` on PyPI). This
|
|
4
|
+
adapter calls the agent, extracts the final response text, the verdict (if
|
|
5
|
+
the agent returns a structured response), and the ordered tool-call names
|
|
6
|
+
from the message's tool-use blocks.
|
|
7
|
+
|
|
8
|
+
The adapter is an optional import: the core library installs without
|
|
9
|
+
``strands-agents``. Import this module only when working with Strands agents.
|
|
10
|
+
|
|
11
|
+
Example::
|
|
12
|
+
|
|
13
|
+
from strands import Agent
|
|
14
|
+
from agentverity.adapters.strands import from_strands
|
|
15
|
+
|
|
16
|
+
agent = Agent(model="...", system_prompt="you are a gate")
|
|
17
|
+
fn = from_strands(agent)
|
|
18
|
+
obs = fn("should I share this?")
|
|
19
|
+
print(obs.text, obs.verdict, obs.tools)
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
from agentverity.observation import Observation
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def from_strands(agent: Any) -> callable:
|
|
30
|
+
"""Wrap a Strands ``Agent`` into ``run(input) -> Observation``.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
agent: A ``strands.agent.Agent`` instance (must be callable with
|
|
34
|
+
``agent(prompt: str)`` and return an ``AgentResult``).
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
A function ``(input: str) -> Observation`` that calls the agent and
|
|
38
|
+
extracts the text, verdict, and tool trajectory from the result.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def run(x: str) -> Observation:
|
|
42
|
+
result = agent(x)
|
|
43
|
+
return _extract(result)
|
|
44
|
+
|
|
45
|
+
return run
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _extract(result: Any) -> Observation:
|
|
49
|
+
"""Extract an :class:`Observation` from a Strands ``AgentResult``.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
result: A Strands ``AgentResult`` (must have ``.message`` with
|
|
53
|
+
``content`` blocks).
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
An :class:`Observation` with the final text, verdict (if any), and
|
|
57
|
+
the ordered list of tool names called.
|
|
58
|
+
"""
|
|
59
|
+
message = getattr(result, "message", None)
|
|
60
|
+
if message is None:
|
|
61
|
+
return Observation(text=str(result), raw=result)
|
|
62
|
+
|
|
63
|
+
content = message.get("content", []) if isinstance(message, dict) else getattr(message, "content", [])
|
|
64
|
+
|
|
65
|
+
text_parts: list[str] = []
|
|
66
|
+
tool_names: list[str] = []
|
|
67
|
+
|
|
68
|
+
for block in content:
|
|
69
|
+
if isinstance(block, dict):
|
|
70
|
+
if "text" in block:
|
|
71
|
+
text_parts.append(str(block["text"]))
|
|
72
|
+
elif "toolUse" in block:
|
|
73
|
+
tool_use = block["toolUse"]
|
|
74
|
+
name = tool_use.get("name", "") if isinstance(tool_use, dict) else ""
|
|
75
|
+
if name:
|
|
76
|
+
tool_names.append(name)
|
|
77
|
+
elif "toolResult" in block:
|
|
78
|
+
tool_result = block["toolResult"]
|
|
79
|
+
name = tool_result.get("name", "") if isinstance(tool_result, dict) else ""
|
|
80
|
+
if name:
|
|
81
|
+
tool_names.append(name)
|
|
82
|
+
else:
|
|
83
|
+
text_parts.append(str(block))
|
|
84
|
+
|
|
85
|
+
text = "".join(text_parts) or str(result)
|
|
86
|
+
structured = getattr(result, "structured_output", None)
|
|
87
|
+
verdict = None
|
|
88
|
+
if structured is not None:
|
|
89
|
+
if hasattr(structured, "model_dump"):
|
|
90
|
+
dump = structured.model_dump()
|
|
91
|
+
verdict = str(dump.get("verdict", dump.get("decision", ""))) or None
|
|
92
|
+
elif isinstance(structured, dict):
|
|
93
|
+
verdict = str(structured.get("verdict", structured.get("decision", ""))) or None
|
|
94
|
+
|
|
95
|
+
return Observation(
|
|
96
|
+
text=text,
|
|
97
|
+
verdict=verdict,
|
|
98
|
+
tools=tuple(tool_names),
|
|
99
|
+
raw=result,
|
|
100
|
+
)
|
agentverity/blindness.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Constant-gate-blindness detector — the honesty instrument.
|
|
2
|
+
|
|
3
|
+
A test suite can pass for the wrong reason: if the agent returns a near-constant
|
|
4
|
+
verdict across a diverse input set, many invariance and monotonicity checks can
|
|
5
|
+
be satisfied without exercising a decision boundary. A green suite can then say
|
|
6
|
+
more about verdict skew than about whether the agent reasons correctly.
|
|
7
|
+
|
|
8
|
+
This detector measures the agent's verdict **skew** on a probe set and warns
|
|
9
|
+
when a pass is likely trivial. It is the feature that makes the framework
|
|
10
|
+
honest about its own limits: it tells you when your passing metamorphic suite
|
|
11
|
+
is lying to you.
|
|
12
|
+
|
|
13
|
+
Example::
|
|
14
|
+
|
|
15
|
+
from agentverity.blindness import detect
|
|
16
|
+
from agentverity.adapters import from_callable
|
|
17
|
+
|
|
18
|
+
agent = from_callable(my_constant_agent)
|
|
19
|
+
result = detect(agent, inputs=["hello", "world", "foo", "bar"])
|
|
20
|
+
if result.blind:
|
|
21
|
+
print(result.warning)
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from collections import Counter
|
|
27
|
+
from collections.abc import Callable, Iterable
|
|
28
|
+
from dataclasses import dataclass
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
from agentverity.observation import Observation
|
|
32
|
+
|
|
33
|
+
AgentFn = Callable[[str], Observation]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class BlindnessResult:
|
|
38
|
+
"""The outcome of a constant-gate-blindness scan.
|
|
39
|
+
|
|
40
|
+
Attributes:
|
|
41
|
+
inputs: Number of inputs probed.
|
|
42
|
+
layer: Which ``Observation`` layer was measured.
|
|
43
|
+
majority_verdict: The most common verdict value observed.
|
|
44
|
+
skew: The share of inputs that returned the majority verdict.
|
|
45
|
+
distinct: Number of distinct verdicts seen.
|
|
46
|
+
threshold: The skew threshold above which the gate is considered blind.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
inputs: int
|
|
50
|
+
layer: str
|
|
51
|
+
majority_verdict: Any
|
|
52
|
+
skew: float
|
|
53
|
+
distinct: int
|
|
54
|
+
threshold: float
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def blind(self) -> bool:
|
|
58
|
+
"""``True`` if verdict skew can make green relation results vacuous."""
|
|
59
|
+
return self.skew >= self.threshold
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def warning(self) -> str | None:
|
|
63
|
+
"""A human-readable warning if the gate is blind, ``None`` otherwise."""
|
|
64
|
+
if not self.blind:
|
|
65
|
+
return None
|
|
66
|
+
return (
|
|
67
|
+
f"constant-gate blindness: agent returns "
|
|
68
|
+
f"{self.majority_verdict!r} on {self.skew:.0%} of inputs. "
|
|
69
|
+
f"Relation passes may be trivial because the probe set rarely "
|
|
70
|
+
f"exercises a decision boundary. A green suite is NOT evidence of "
|
|
71
|
+
f"correct behaviour here."
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def detect(
|
|
76
|
+
agent: AgentFn,
|
|
77
|
+
inputs: Iterable[str],
|
|
78
|
+
*,
|
|
79
|
+
layer: str = "verdict",
|
|
80
|
+
threshold: float = 0.9,
|
|
81
|
+
) -> BlindnessResult:
|
|
82
|
+
"""Detect whether an agent is near-constant and passes may be vacuous.
|
|
83
|
+
|
|
84
|
+
The agent is called once on each input and the verdict distribution is
|
|
85
|
+
measured. If a single verdict accounts for at least ``threshold`` of the
|
|
86
|
+
inputs, the gate is flagged as blind. High skew makes relation passes
|
|
87
|
+
vulnerable to being vacuous because few probes exercise a decision
|
|
88
|
+
boundary. The detector is a warning about suite power, not a correctness
|
|
89
|
+
judgement about the agent.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
agent: An agent function ``run(input) -> Observation``.
|
|
93
|
+
inputs: An iterable of input strings to probe. Use a diverse set;
|
|
94
|
+
a narrow set inflates skew artificially.
|
|
95
|
+
layer: Which ``Observation`` layer to measure (``"verdict"``,
|
|
96
|
+
``"text"``, or ``"tools"``).
|
|
97
|
+
threshold: The skew share (0–1) above which the gate is considered
|
|
98
|
+
blind (default 0.9, i.e. 90%).
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
A :class:`BlindnessResult` with the skew, distinct-verdict count,
|
|
102
|
+
and blind flag.
|
|
103
|
+
|
|
104
|
+
Raises:
|
|
105
|
+
ValueError: If inputs is empty or threshold is outside ``(0, 1]``.
|
|
106
|
+
"""
|
|
107
|
+
inputs = list(inputs)
|
|
108
|
+
if not inputs:
|
|
109
|
+
raise ValueError("inputs must not be empty")
|
|
110
|
+
if not 0 < threshold <= 1:
|
|
111
|
+
raise ValueError("threshold must be between 0 and 1")
|
|
112
|
+
return score([agent(x) for x in inputs], layer=layer, threshold=threshold)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def score(
|
|
116
|
+
observations: Iterable[Observation],
|
|
117
|
+
*,
|
|
118
|
+
layer: str = "verdict",
|
|
119
|
+
threshold: float = 0.9,
|
|
120
|
+
) -> BlindnessResult:
|
|
121
|
+
"""Score already-collected observations for verdict skew.
|
|
122
|
+
|
|
123
|
+
:func:`detect` calls the agent and then delegates here. Call this directly
|
|
124
|
+
when the observations were gathered by an earlier phase, so a probe set does
|
|
125
|
+
not have to be re-run purely to count its verdict distribution.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
observations: One :class:`Observation` per input, already collected.
|
|
129
|
+
layer: Which ``Observation`` layer to measure (``"verdict"``,
|
|
130
|
+
``"text"``, or ``"tools"``).
|
|
131
|
+
threshold: The skew share (0–1) above which the gate is considered
|
|
132
|
+
blind (default 0.9, i.e. 90%).
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
A :class:`BlindnessResult` with the skew, distinct-verdict count,
|
|
136
|
+
and blind flag.
|
|
137
|
+
|
|
138
|
+
Raises:
|
|
139
|
+
ValueError: If observations is empty or threshold is outside ``(0, 1]``.
|
|
140
|
+
"""
|
|
141
|
+
observations = list(observations)
|
|
142
|
+
if not observations:
|
|
143
|
+
raise ValueError("observations must not be empty")
|
|
144
|
+
if not 0 < threshold <= 1:
|
|
145
|
+
raise ValueError("threshold must be between 0 and 1")
|
|
146
|
+
verdicts = [_hashable(obs.key(layer)) for obs in observations]
|
|
147
|
+
c = Counter(verdicts)
|
|
148
|
+
top, top_n = c.most_common(1)[0]
|
|
149
|
+
n = len(verdicts)
|
|
150
|
+
return BlindnessResult(
|
|
151
|
+
inputs=n,
|
|
152
|
+
layer=layer,
|
|
153
|
+
majority_verdict=top,
|
|
154
|
+
skew=top_n / n,
|
|
155
|
+
distinct=len(c),
|
|
156
|
+
threshold=threshold,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _hashable(v: Any) -> Any:
|
|
161
|
+
"""Normalise a verdict value to a hashable comparison key."""
|
|
162
|
+
if isinstance(v, (list, tuple)):
|
|
163
|
+
return tuple(v)
|
|
164
|
+
return v.value if hasattr(v, "value") else v
|
agentverity/cli.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""CLI entry point for agentverity.
|
|
2
|
+
|
|
3
|
+
Usage::
|
|
4
|
+
|
|
5
|
+
agentverity run --agent mymod:build_agent --inputs seeds.txt
|
|
6
|
+
agentverity run --agent mymod:build_agent --inputs seeds.txt --k 10 --epsilon 0.02
|
|
7
|
+
|
|
8
|
+
The ``--agent`` argument is a Python dotted path to a callable that returns
|
|
9
|
+
the agent function (``fn() -> (str) -> Observation``). The ``--inputs``
|
|
10
|
+
argument is a text file with one input per line.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import importlib
|
|
17
|
+
import sys
|
|
18
|
+
from collections.abc import Callable
|
|
19
|
+
|
|
20
|
+
from agentverity.adapters.callable_adapter import from_callable
|
|
21
|
+
from agentverity.runner import RunConfig, run
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _load_agent(spec: str) -> Callable:
|
|
25
|
+
"""Load an agent factory from a ``module:func`` spec."""
|
|
26
|
+
if ":" not in spec:
|
|
27
|
+
raise ValueError(
|
|
28
|
+
f"--agent must be 'module:func', got {spec!r}"
|
|
29
|
+
)
|
|
30
|
+
module_path, func_name = spec.split(":", 1)
|
|
31
|
+
module = importlib.import_module(module_path)
|
|
32
|
+
factory = getattr(module, func_name)
|
|
33
|
+
if not callable(factory):
|
|
34
|
+
raise TypeError(f"{spec!r} is not callable")
|
|
35
|
+
return factory
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _load_inputs(path: str) -> list[str]:
|
|
39
|
+
"""Load inputs from a text file, one per line, skipping blanks."""
|
|
40
|
+
with open(path) as f:
|
|
41
|
+
return [line.strip() for line in f if line.strip()]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def main(argv: list[str] | None = None) -> int:
|
|
45
|
+
"""Run the agentverity CLI.
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
0 if all relations hold and no blindness is detected, 1 otherwise.
|
|
49
|
+
"""
|
|
50
|
+
parser = argparse.ArgumentParser(
|
|
51
|
+
prog="agentverity",
|
|
52
|
+
description="Measure-first testing for non-deterministic LLM agents.",
|
|
53
|
+
)
|
|
54
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
55
|
+
|
|
56
|
+
run_parser = sub.add_parser("run", help="Run the diagnostic suite on an agent.")
|
|
57
|
+
run_parser.add_argument(
|
|
58
|
+
"--agent", required=True,
|
|
59
|
+
help="Python dotted path to an agent factory: 'module:func'. "
|
|
60
|
+
"The factory is called with no args and must return a "
|
|
61
|
+
"callable (str) -> Observation.",
|
|
62
|
+
)
|
|
63
|
+
run_parser.add_argument(
|
|
64
|
+
"--inputs", required=True,
|
|
65
|
+
help="Path to a text file with one input per line.",
|
|
66
|
+
)
|
|
67
|
+
run_parser.add_argument("--k", type=int, default=5, help="Meter repeats per input (default 5).")
|
|
68
|
+
run_parser.add_argument("--epsilon", type=float, default=0.01, help="Meter epsilon (default 0.01).")
|
|
69
|
+
run_parser.add_argument(
|
|
70
|
+
"--blindness-threshold", type=float, default=0.9,
|
|
71
|
+
help="Blindness skew threshold (default 0.9).",
|
|
72
|
+
)
|
|
73
|
+
run_parser.add_argument("--layer", default="verdict", help="Observation layer to measure (default 'verdict').")
|
|
74
|
+
run_parser.add_argument(
|
|
75
|
+
"--no-meter", action="store_true",
|
|
76
|
+
help="Skip the verdict-stochasticity meter.",
|
|
77
|
+
)
|
|
78
|
+
run_parser.add_argument(
|
|
79
|
+
"--no-blindness", action="store_true",
|
|
80
|
+
help="Skip the constant-gate-blindness detector.",
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
args = parser.parse_args(argv)
|
|
84
|
+
|
|
85
|
+
if args.command == "run":
|
|
86
|
+
factory = _load_agent(args.agent)
|
|
87
|
+
# from_callable passes Observation objects through unchanged, so the
|
|
88
|
+
# CLI can adapt every factory without a side-effecting probe call.
|
|
89
|
+
agent_fn = from_callable(factory())
|
|
90
|
+
inputs = _load_inputs(args.inputs)
|
|
91
|
+
config = RunConfig(
|
|
92
|
+
k=args.k,
|
|
93
|
+
epsilon=args.epsilon,
|
|
94
|
+
blindness_threshold=args.blindness_threshold,
|
|
95
|
+
layer=args.layer,
|
|
96
|
+
run_meter=not args.no_meter,
|
|
97
|
+
run_blindness=not args.no_blindness,
|
|
98
|
+
)
|
|
99
|
+
result = run(agent_fn, inputs, config=config)
|
|
100
|
+
print(result.summary())
|
|
101
|
+
|
|
102
|
+
# Exit code: 1 if blind or any relation violated, 0 otherwise
|
|
103
|
+
if result.is_blind:
|
|
104
|
+
return 1
|
|
105
|
+
if any(rr.violated > 0 for rr in result.relation_results):
|
|
106
|
+
return 1
|
|
107
|
+
return 0
|
|
108
|
+
|
|
109
|
+
return 0
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
if __name__ == "__main__":
|
|
113
|
+
sys.exit(main())
|
agentverity/meter.py
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""Verdict-stochasticity meter — the measure-first instrument.
|
|
2
|
+
|
|
3
|
+
Agents are non-deterministic, so before trusting any test you must know whether
|
|
4
|
+
the agent's *decision* actually varies across identical reruns, and at which
|
|
5
|
+
layer. Token-level variation (the final text wording) is common and mostly
|
|
6
|
+
harmless; what matters for testing is whether the **verdict** (the categorical
|
|
7
|
+
decision, or the tool trajectory) flips. If the verdict is stable and a
|
|
8
|
+
trusted reference is available, frozen-baseline diffing is the more sensitive
|
|
9
|
+
change detector.
|
|
10
|
+
If it is stochastic, you need noise-robust relations and a measured baseline,
|
|
11
|
+
not zero-tolerance assertions.
|
|
12
|
+
|
|
13
|
+
The meter runs the agent ``k`` times on each unchanged input and reports a
|
|
14
|
+
tri-state call with a Wilson confidence interval, so an underfunded probe is
|
|
15
|
+
reported as ``"undecided"`` rather than mislabelled ``"deterministic"``.
|
|
16
|
+
|
|
17
|
+
Example::
|
|
18
|
+
|
|
19
|
+
from agentverity.meter import measure
|
|
20
|
+
from agentverity.adapters import from_callable
|
|
21
|
+
|
|
22
|
+
agent = from_callable(my_agent_fn)
|
|
23
|
+
result = measure(agent, inputs=["hello", "world"], k=5)
|
|
24
|
+
print(result.call) # "verdict-deterministic" / "verdict-stochastic" / "undecided"
|
|
25
|
+
print(result.advice) # human-readable recommendation
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import math
|
|
31
|
+
from collections.abc import Callable, Iterable
|
|
32
|
+
from dataclasses import dataclass
|
|
33
|
+
from typing import Any
|
|
34
|
+
|
|
35
|
+
from agentverity.observation import Observation
|
|
36
|
+
|
|
37
|
+
AgentFn = Callable[[str], Observation]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def wilson_ci(successes: int, trials: int, z: float = 1.96) -> tuple[float, float]:
|
|
41
|
+
"""Return the Wilson score interval for a binomial proportion.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
successes: Number of successes observed.
|
|
45
|
+
trials: Total number of trials.
|
|
46
|
+
z: Z-value for the desired confidence level (default 1.96 for 95%).
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
A ``(low, high)`` tuple with the lower and upper bounds of the
|
|
50
|
+
confidence interval. Returns ``(0.0, 0.0)`` if ``trials`` is zero.
|
|
51
|
+
"""
|
|
52
|
+
if trials == 0:
|
|
53
|
+
return 0.0, 0.0
|
|
54
|
+
p = successes / trials
|
|
55
|
+
denom = 1 + z * z / trials
|
|
56
|
+
centre = (p + z * z / (2 * trials)) / denom
|
|
57
|
+
margin = z * math.sqrt((p * (1 - p) + z * z / (4 * trials)) / trials) / denom
|
|
58
|
+
return max(0.0, centre - margin), min(1.0, centre + margin)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True)
|
|
62
|
+
class MeterResult:
|
|
63
|
+
"""The outcome of a verdict-stochasticity measurement.
|
|
64
|
+
|
|
65
|
+
Attributes:
|
|
66
|
+
layer: Which ``Observation`` layer was measured (``"verdict"``,
|
|
67
|
+
``"text"``, or ``"tools"``).
|
|
68
|
+
epsilon: The flip-rate threshold below which a gate is considered
|
|
69
|
+
deterministic.
|
|
70
|
+
inputs: Number of distinct inputs probed.
|
|
71
|
+
repeats: Number of repeated calls per input (``k``).
|
|
72
|
+
pair_trials: Total independent, disjoint comparisons across all inputs.
|
|
73
|
+
pair_flips: Number of disjoint comparisons where the verdict differed.
|
|
74
|
+
inputs_with_flip: Number of inputs that showed at least one flip.
|
|
75
|
+
ci_low: Lower bound of the Wilson CI on the flip rate.
|
|
76
|
+
ci_high: Upper bound of the Wilson CI on the flip rate.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
layer: str
|
|
80
|
+
epsilon: float
|
|
81
|
+
inputs: int
|
|
82
|
+
repeats: int
|
|
83
|
+
pair_trials: int
|
|
84
|
+
pair_flips: int
|
|
85
|
+
inputs_with_flip: int
|
|
86
|
+
ci_low: float
|
|
87
|
+
ci_high: float
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def flip_rate(self) -> float:
|
|
91
|
+
"""The observed pairwise flip rate (``pair_flips / pair_trials``)."""
|
|
92
|
+
return self.pair_flips / self.pair_trials if self.pair_trials else 0.0
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def call(self) -> str:
|
|
96
|
+
"""The tri-state classification.
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
``"verdict-stochastic"`` if the CI lower bound is above epsilon,
|
|
100
|
+
``"verdict-deterministic"`` if the CI upper bound is below epsilon,
|
|
101
|
+
or ``"undecided (add repeats or inputs)"`` if the interval straddles
|
|
102
|
+
epsilon. A bare ``"deterministic"`` would conflate real stability
|
|
103
|
+
with an underpowered probe, so an interval straddling epsilon is
|
|
104
|
+
``"undecided"``.
|
|
105
|
+
"""
|
|
106
|
+
if self.ci_low > self.epsilon:
|
|
107
|
+
return "verdict-stochastic"
|
|
108
|
+
if self.ci_high < self.epsilon:
|
|
109
|
+
return "verdict-deterministic"
|
|
110
|
+
return "undecided (add repeats or inputs)"
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def advice(self) -> str:
|
|
114
|
+
"""A human-readable recommendation based on the tri-state call."""
|
|
115
|
+
c = self.call
|
|
116
|
+
if c == "verdict-stochastic":
|
|
117
|
+
return ("verdict varies across identical runs: use noise-robust "
|
|
118
|
+
"relations and compare violations to a measured baseline, "
|
|
119
|
+
"not zero.")
|
|
120
|
+
if c == "verdict-deterministic":
|
|
121
|
+
return ("verdict is stable: prefer frozen-baseline diffing when "
|
|
122
|
+
"a trusted reference is available.")
|
|
123
|
+
return "not enough evidence to choose an oracle; raise K or input count."
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def measure(
|
|
127
|
+
agent: AgentFn,
|
|
128
|
+
inputs: Iterable[str],
|
|
129
|
+
*,
|
|
130
|
+
k: int = 5,
|
|
131
|
+
layer: str = "verdict",
|
|
132
|
+
epsilon: float = 0.01,
|
|
133
|
+
) -> MeterResult:
|
|
134
|
+
"""Measure the verdict-stochasticity of an agent.
|
|
135
|
+
|
|
136
|
+
The agent is called ``k`` times on each input (with no transform applied).
|
|
137
|
+
Consecutive outputs are compared in disjoint pairs, giving
|
|
138
|
+
:math:`\\lfloor k/2 \\rfloor` independent comparisons per input. A Wilson
|
|
139
|
+
confidence interval on that flip rate determines the tri-state call.
|
|
140
|
+
|
|
141
|
+
Using every pair among the same ``k`` outputs would create
|
|
142
|
+
:math:`\\binom{k}{2}` dependent comparisons and make the interval look
|
|
143
|
+
more precise than the calls justify. An odd final repeat is retained for
|
|
144
|
+
``inputs_with_flip`` but does not enter the confidence interval.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
agent: An agent function ``run(input) -> Observation``.
|
|
148
|
+
inputs: An iterable of input strings to probe.
|
|
149
|
+
k: Number of repeated calls per input (must be >= 2). The confidence
|
|
150
|
+
interval uses ``floor(k / 2)`` disjoint pairs.
|
|
151
|
+
layer: Which ``Observation`` layer to measure (``"verdict"``,
|
|
152
|
+
``"text"``, or ``"tools"``).
|
|
153
|
+
epsilon: The flip-rate threshold below which a gate is considered
|
|
154
|
+
deterministic (default 0.01, i.e. 1%).
|
|
155
|
+
|
|
156
|
+
Returns:
|
|
157
|
+
A :class:`MeterResult` with the flip rate, Wilson CI, and tri-state call.
|
|
158
|
+
|
|
159
|
+
Raises:
|
|
160
|
+
ValueError: If ``k`` is less than 2, inputs is empty, or epsilon is
|
|
161
|
+
outside ``(0, 1)``.
|
|
162
|
+
"""
|
|
163
|
+
if k < 2:
|
|
164
|
+
raise ValueError("k must be >= 2 to compare repeated runs")
|
|
165
|
+
inputs = list(inputs)
|
|
166
|
+
if not inputs:
|
|
167
|
+
raise ValueError("inputs must not be empty")
|
|
168
|
+
if not 0 < epsilon < 1:
|
|
169
|
+
raise ValueError("epsilon must be between 0 and 1")
|
|
170
|
+
pair_trials = 0
|
|
171
|
+
pair_flips = 0
|
|
172
|
+
inputs_with_flip = 0
|
|
173
|
+
for x in inputs:
|
|
174
|
+
keys = [agent(x).key(layer) for _ in range(k)]
|
|
175
|
+
if len({_hashable(v) for v in keys}) > 1:
|
|
176
|
+
inputs_with_flip += 1
|
|
177
|
+
for i in range(0, k - 1, 2):
|
|
178
|
+
pair_trials += 1
|
|
179
|
+
if _hashable(keys[i]) != _hashable(keys[i + 1]):
|
|
180
|
+
pair_flips += 1
|
|
181
|
+
lo, hi = wilson_ci(pair_flips, pair_trials)
|
|
182
|
+
return MeterResult(
|
|
183
|
+
layer=layer,
|
|
184
|
+
epsilon=epsilon,
|
|
185
|
+
inputs=len(inputs),
|
|
186
|
+
repeats=k,
|
|
187
|
+
pair_trials=pair_trials,
|
|
188
|
+
pair_flips=pair_flips,
|
|
189
|
+
inputs_with_flip=inputs_with_flip,
|
|
190
|
+
ci_low=lo,
|
|
191
|
+
ci_high=hi,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _hashable(v: Any) -> Any:
|
|
196
|
+
"""Normalise a verdict value to a hashable comparison key.
|
|
197
|
+
|
|
198
|
+
Verdicts may be strings, enums, or tuples (for tool trajectories).
|
|
199
|
+
Lists are converted to tuples; enum values are extracted via ``.value``
|
|
200
|
+
if present.
|
|
201
|
+
"""
|
|
202
|
+
if isinstance(v, (list, tuple)):
|
|
203
|
+
return tuple(v)
|
|
204
|
+
return v.value if hasattr(v, "value") else v
|