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.
@@ -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
+ )
@@ -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