lazycontroller 0.1.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,161 @@
1
+ """Read an OpenAI-style request into a decision state and a JSON schema, or say why it can't be.
2
+
3
+ Only parameters that don't change what a correct answer is may be ignored. Anything else
4
+ (tools, streaming, audio, logprobs, stored conversations, extra request options) means the
5
+ caller relies on behaviour only the slow model has, so the request goes to it untouched.
6
+ """
7
+
8
+ from collections.abc import Iterable, Mapping
9
+ from dataclasses import dataclass
10
+ from typing import Any
11
+
12
+ from openai import NotGiven, Omit
13
+
14
+ from .schema import Ineligible
15
+
16
+ # Sampling, limits and bookkeeping: they shape how a model generates, not what the answer is.
17
+ _IGNORABLE = {
18
+ "frequency_penalty",
19
+ "max_completion_tokens",
20
+ "max_output_tokens",
21
+ "max_tokens",
22
+ "metadata",
23
+ "model",
24
+ "presence_penalty",
25
+ "prompt_cache_key",
26
+ "prompt_cache_retention",
27
+ "reasoning",
28
+ "reasoning_effort",
29
+ "safety_identifier",
30
+ "seed",
31
+ "service_tier",
32
+ "store",
33
+ "temperature",
34
+ "timeout",
35
+ "top_p",
36
+ "truncation",
37
+ "user",
38
+ "verbosity",
39
+ }
40
+ _CHAT_INPUTS = {"messages", "response_format"}
41
+ _RESPONSES_INPUTS = {"input", "instructions", "text"}
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class Request:
46
+ state: dict[str, Any]
47
+ """``{"conversation": [{"role": ..., "content": ...}, ...]}``: structured, as System One
48
+ servers recommend. Servers that accept only a string state can't be used as the fast model."""
49
+ schema: Mapping[str, Any]
50
+ name: str | None
51
+
52
+
53
+ def given(kwargs: Mapping[str, Any]) -> dict[str, Any]:
54
+ """Drop the SDK's ``omit``/``NOT_GIVEN`` placeholders."""
55
+ return {k: v for k, v in kwargs.items() if not isinstance(v, (Omit, NotGiven))}
56
+
57
+
58
+ def chat_request(kwargs: Mapping[str, Any]) -> Request:
59
+ kwargs = given(kwargs)
60
+ _check_params(
61
+ kwargs,
62
+ _CHAT_INPUTS,
63
+ falsy={"stream", "logprobs", "parallel_tool_calls"},
64
+ n_ok=True,
65
+ )
66
+ response_format = kwargs.get("response_format")
67
+ if not isinstance(response_format, Mapping) or response_format.get("type") != "json_schema":
68
+ raise Ineligible("no json_schema response_format")
69
+ json_schema = response_format.get("json_schema") or {}
70
+ turns = [_chat_turn(message) for message in kwargs.get("messages") or ()]
71
+ return _request(turns, json_schema.get("schema"), json_schema.get("name"))
72
+
73
+
74
+ def responses_request(kwargs: Mapping[str, Any]) -> Request:
75
+ kwargs = given(kwargs)
76
+ _check_params(kwargs, _RESPONSES_INPUTS, falsy={"stream", "background", "parallel_tool_calls"})
77
+ text_format = (kwargs.get("text") or {}).get("format") or {}
78
+ if text_format.get("type") != "json_schema":
79
+ raise Ineligible("no json_schema text format")
80
+ turns = []
81
+ if instructions := kwargs.get("instructions"):
82
+ turns.append({"role": "system", "content": _text(instructions)})
83
+ match kwargs.get("input"):
84
+ case str() as text:
85
+ turns.append({"role": "user", "content": text})
86
+ case None:
87
+ pass
88
+ case items:
89
+ turns.extend(_responses_turn(item) for item in items)
90
+ return _request(turns, text_format.get("schema"), text_format.get("name"))
91
+
92
+
93
+ def _check_params(
94
+ kwargs: Mapping[str, Any], inputs: set[str], *, falsy: set[str], n_ok: bool = False
95
+ ) -> None:
96
+ for key, value in kwargs.items():
97
+ if key in inputs or key in _IGNORABLE:
98
+ continue
99
+ if key in falsy and not value:
100
+ continue
101
+ if n_ok and key == "n" and value in (None, 1):
102
+ continue
103
+ if key in {"extra_headers", "extra_query", "extra_body"} and not value:
104
+ continue
105
+ raise Ineligible(f"uses {key}")
106
+
107
+
108
+ def _request(turns: list[dict[str, str]], schema: Any, name: str | None) -> Request:
109
+ if not isinstance(schema, Mapping):
110
+ raise Ineligible("json_schema has no schema")
111
+ if not any(turn["role"] == "user" for turn in turns):
112
+ raise Ineligible("no user message")
113
+ return Request({"conversation": turns}, schema, name)
114
+
115
+
116
+ def _chat_turn(message: Any) -> dict[str, str]:
117
+ if not isinstance(message, Mapping):
118
+ raise Ineligible("message is not a dict")
119
+ role = message.get("role")
120
+ if role not in {"system", "developer", "user", "assistant"}:
121
+ raise Ineligible(f"{role} message")
122
+ if message.get("tool_calls") or message.get("function_call") or message.get("audio"):
123
+ raise Ineligible("assistant tool call in history")
124
+ return {
125
+ "role": "system" if role == "developer" else role,
126
+ "content": _text(message.get("content")),
127
+ }
128
+
129
+
130
+ def _responses_turn(item: Any) -> dict[str, str]:
131
+ if not isinstance(item, Mapping) or item.get("type", "message") != "message":
132
+ raise Ineligible("non-message input item")
133
+ role = item.get("role")
134
+ if role not in {"system", "developer", "user", "assistant"}:
135
+ raise Ineligible(f"{role} input")
136
+ return {
137
+ "role": "system" if role == "developer" else role,
138
+ "content": _text(item.get("content")),
139
+ }
140
+
141
+
142
+ def _text(content: Any) -> str:
143
+ """Flatten text content; images, audio and files can't go to the fast model as plain text."""
144
+ match content:
145
+ case str():
146
+ return content
147
+ case None:
148
+ return ""
149
+ case Iterable():
150
+ parts = []
151
+ for part in content:
152
+ if not isinstance(part, Mapping) or part.get("type") not in {
153
+ "text",
154
+ "input_text",
155
+ "output_text",
156
+ }:
157
+ raise Ineligible("non-text content part")
158
+ parts.append(part.get("text") or "")
159
+ return "\n".join(parts)
160
+ case _:
161
+ raise Ineligible("unrecognized message content")
@@ -0,0 +1,298 @@
1
+ """Turn a structured-output JSON schema into decision questions, and the answers back into JSON.
2
+
3
+ A schema is eligible only when every value it asks for is a closed decision:
4
+
5
+ - ``enum`` (from ``Literal``, ``Enum``, ...) becomes a Choice;
6
+ - ``boolean`` becomes a Noul;
7
+ - ``integer`` with a small ``minimum``..``maximum`` range becomes a Score, one level per value;
8
+ - ``const`` is filled in without asking;
9
+ - objects are walked field by field, and ``X | None`` of an enum or boolean becomes a Choice
10
+ with a ``null`` option.
11
+
12
+ Anything else (free strings, arrays, unbounded numbers, unions) needs a generative model, so the
13
+ whole request is left to the slow model.
14
+ """
15
+
16
+ import json
17
+ import re
18
+ from collections.abc import Callable, Mapping
19
+ from dataclasses import dataclass
20
+ from typing import Any
21
+
22
+ from typesafe_sdk import (
23
+ Answer,
24
+ Choice,
25
+ ChoiceAnswer,
26
+ Noul,
27
+ NoulAnswer,
28
+ Score,
29
+ ScoreAnswer,
30
+ )
31
+
32
+ MAX_SCORE_LEVELS = 11
33
+ """Integer ranges wider than this are treated as open numbers, not a rubric."""
34
+
35
+ _MAX_DEPTH = 16
36
+
37
+ # Keywords whose meaning we honour. Any other constraint (multipleOf, pattern, format, ...)
38
+ # could reject a value the fast model picks, so it makes the request ineligible rather than ignored.
39
+ _ANNOTATIONS = {"title", "description", "default", "examples", "$comment", "deprecated"}
40
+ _KEYWORDS = _ANNOTATIONS | {
41
+ "type",
42
+ "enum",
43
+ "const",
44
+ "properties",
45
+ "required",
46
+ "additionalProperties",
47
+ "minimum",
48
+ "maximum",
49
+ "exclusiveMinimum",
50
+ "exclusiveMaximum",
51
+ "$defs",
52
+ "definitions",
53
+ }
54
+
55
+ type Path = tuple[str, ...]
56
+ type Decoder = Callable[[Any], tuple[Any, float]]
57
+
58
+
59
+ class Ineligible(Exception):
60
+ """The fast model can't answer this request; the message says why."""
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class Field:
65
+ path: Path
66
+ question: Choice | Noul | Score
67
+ decode: Decoder
68
+ """Maps the decision answer to (JSON value, confidence in 0..1)."""
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class Plan:
73
+ """The decision questions for one schema, plus the values that need no question."""
74
+
75
+ fields: tuple[Field, ...]
76
+ constants: tuple[tuple[Path, Any], ...]
77
+
78
+ @property
79
+ def questions(self) -> dict[str, Choice | Noul | Score]:
80
+ return {_question_id(i, f.path): f.question for i, f in enumerate(self.fields)}
81
+
82
+ def assemble(self, answers: Mapping[str, Answer]) -> tuple[dict[str, Any], dict[str, float]]:
83
+ """Build the output object; also return each field's confidence keyed by dotted path."""
84
+ output: dict[str, Any] = {}
85
+ confidences: dict[str, float] = {}
86
+ for path, value in self.constants:
87
+ _put(output, path, value)
88
+ for i, f in enumerate(self.fields):
89
+ value, confidence = f.decode(answers[_question_id(i, f.path)])
90
+ _put(output, f.path, value)
91
+ confidences[".".join(f.path)] = confidence
92
+ return output, confidences
93
+
94
+
95
+ def compile_schema(schema: Mapping[str, Any], *, title: str | None = None) -> Plan:
96
+ """Raise Ineligible unless every value in ``schema`` maps to a decision question."""
97
+ fields: list[Field] = []
98
+ constants: list[tuple[Path, Any]] = []
99
+ _walk(schema, schema, (), title or schema.get("title"), fields, constants, 0)
100
+ if not fields:
101
+ raise Ineligible("schema has no fields to decide")
102
+ return Plan(tuple(fields), tuple(constants))
103
+
104
+
105
+ def _walk(
106
+ node: Mapping[str, Any],
107
+ root: Mapping[str, Any],
108
+ path: Path,
109
+ title: str | None,
110
+ fields: list[Field],
111
+ constants: list[tuple[Path, Any]],
112
+ depth: int,
113
+ ) -> None:
114
+ if depth > _MAX_DEPTH:
115
+ raise Ineligible("schema is nested too deeply")
116
+ node = _resolve(node, root)
117
+ where = ".".join(path) or "<root>"
118
+
119
+ if "const" in node:
120
+ if not path:
121
+ raise Ineligible("root schema is a constant")
122
+ constants.append((path, node["const"]))
123
+ return
124
+
125
+ if _type(node) == "object" or "properties" in node:
126
+ if not path and _type(node) != "object":
127
+ raise Ineligible("root schema is not an object")
128
+ properties = node.get("properties") or {}
129
+ if not properties:
130
+ raise Ineligible(f"{where}: object without properties")
131
+ if node.get("patternProperties") or isinstance(node.get("additionalProperties"), dict):
132
+ raise Ineligible(f"{where}: open-ended object")
133
+ _check_keywords(node, where)
134
+ for name, child in properties.items():
135
+ _walk(child, root, (*path, name), title, fields, constants, depth + 1)
136
+ return
137
+
138
+ if not path:
139
+ raise Ineligible("root schema is not an object")
140
+ fields.append(_leaf(node, root, path, title))
141
+
142
+
143
+ def _leaf(node: Mapping[str, Any], root: Mapping[str, Any], path: Path, title: str | None) -> Field:
144
+ where = ".".join(path)
145
+ instructions = _instructions(node, path, title)
146
+
147
+ nullable, node = _split_nullable(node, root)
148
+ field = _leaf_field(node, path, instructions, nullable)
149
+ _check_keywords(node, where)
150
+ return field
151
+
152
+
153
+ def _leaf_field(node: Mapping[str, Any], path: Path, instructions: str, nullable: bool) -> Field:
154
+ where = ".".join(path)
155
+ if "enum" in node:
156
+ return _choice(path, instructions, list(node["enum"]), nullable)
157
+ match _type(node):
158
+ case "boolean" if nullable:
159
+ return _choice(path, instructions, [True, False], nullable=True)
160
+ case "boolean":
161
+ return Field(path, Noul(instructions=instructions), _decode_noul)
162
+ case "integer" if not nullable:
163
+ return _score(path, instructions, node)
164
+ case "string":
165
+ raise Ineligible(f"{where}: free-text string")
166
+ case "array":
167
+ raise Ineligible(f"{where}: array")
168
+ case "number" | "integer":
169
+ raise Ineligible(f"{where}: open-ended number")
170
+ case _:
171
+ raise Ineligible(f"{where}: unsupported schema {json.dumps(node)[:80]}")
172
+
173
+
174
+ def _choice(path: Path, instructions: str, values: list[Any], nullable: bool) -> Field:
175
+ if nullable and None not in values:
176
+ values = [*values, None]
177
+ if not values:
178
+ raise Ineligible(f"{'.'.join(path)}: empty enum")
179
+ labels = {json.dumps(v) if not isinstance(v, str) else v: v for v in values}
180
+ if len(labels) != len(values):
181
+ raise Ineligible(f"{'.'.join(path)}: enum values collide as labels")
182
+ criteria = {
183
+ label: ("Not stated or not applicable" if v is None else None)
184
+ for label, v in labels.items()
185
+ }
186
+
187
+ def decode(answer: Any) -> tuple[Any, float]:
188
+ if not isinstance(answer, ChoiceAnswer) or answer.choice not in labels:
189
+ raise TypeError(f"expected a choice among {list(labels)}, got {answer!r}")
190
+ return labels[answer.choice], answer.confidence
191
+
192
+ return Field(path, Choice(instructions=instructions, criteria=criteria), decode)
193
+
194
+
195
+ def _decode_noul(answer: Any) -> tuple[Any, float]:
196
+ if not isinstance(answer, NoulAnswer):
197
+ raise TypeError(f"expected a noul, got {answer!r}")
198
+ # Nouls carry no confidence; |2p - 1| is the one TypeSafe suggests, on the Choice scale.
199
+ return answer.noul >= 0.5, abs(2 * answer.noul - 1)
200
+
201
+
202
+ def _score(path: Path, instructions: str, node: Mapping[str, Any]) -> Field:
203
+ where = ".".join(path)
204
+ low, high = node.get("minimum"), node.get("maximum")
205
+ if "exclusiveMinimum" in node:
206
+ low = node["exclusiveMinimum"] + 1
207
+ if "exclusiveMaximum" in node:
208
+ high = node["exclusiveMaximum"] - 1
209
+ if not isinstance(low, int) or not isinstance(high, int):
210
+ raise Ineligible(f"{where}: integer without minimum and maximum")
211
+ if not 2 <= high - low + 1 <= MAX_SCORE_LEVELS:
212
+ raise Ineligible(f"{where}: integer range {low}..{high} is not a small rubric")
213
+ levels = [f"{path[-1]} = {value}" for value in range(low, high + 1)]
214
+
215
+ def decode(answer: Any) -> tuple[Any, float]:
216
+ if not isinstance(answer, ScoreAnswer):
217
+ raise TypeError(f"expected a score, got {answer!r}")
218
+ # The most likely level, not the expected score: the schema wants one of its integers.
219
+ level = max(answer.probabilities, key=answer.probabilities.__getitem__)
220
+ return low + level, answer.confidence
221
+
222
+ return Field(path, Score(instructions=instructions, criteria=levels), decode)
223
+
224
+
225
+ def _split_nullable(
226
+ node: Mapping[str, Any], root: Mapping[str, Any]
227
+ ) -> tuple[bool, Mapping[str, Any]]:
228
+ """Unwrap ``X | None`` written as anyOf/oneOf or as a ``type`` list."""
229
+ for key in ("anyOf", "oneOf"):
230
+ if key in node:
231
+ options = [_resolve(o, root) for o in node[key]]
232
+ rest = [o for o in options if _type(o) != "null"]
233
+ if len(rest) != 1:
234
+ raise Ineligible("union of several types")
235
+ merged = {**{k: v for k, v in node.items() if k != key}, **rest[0]}
236
+ return len(rest) < len(options), merged
237
+ types = node.get("type")
238
+ if isinstance(types, list):
239
+ rest = [t for t in types if t != "null"]
240
+ if len(rest) != 1:
241
+ raise Ineligible("union of several types")
242
+ return len(rest) < len(types), {**node, "type": rest[0]}
243
+ return False, node
244
+
245
+
246
+ def _check_keywords(node: Mapping[str, Any], where: str) -> None:
247
+ unknown = sorted(set(node) - _KEYWORDS)
248
+ if unknown:
249
+ raise Ineligible(f"{where}: unsupported keyword {', '.join(unknown)}")
250
+ if node.get("additionalProperties") not in (None, False, True):
251
+ raise Ineligible(f"{where}: open-ended object")
252
+
253
+
254
+ def _resolve(node: Mapping[str, Any], root: Mapping[str, Any]) -> Mapping[str, Any]:
255
+ seen = 0
256
+ while "$ref" in node:
257
+ ref = node["$ref"]
258
+ if not ref.startswith("#/"):
259
+ raise Ineligible(f"external $ref {ref}")
260
+ target: Any = root
261
+ for part in ref[2:].split("/"):
262
+ target = target[part.replace("~1", "/").replace("~0", "~")]
263
+ # Sibling keywords (e.g. a field description next to an Enum $ref) win over the target.
264
+ node = {**target, **{k: v for k, v in node.items() if k != "$ref"}}
265
+ seen += 1
266
+ if seen > _MAX_DEPTH:
267
+ raise Ineligible("recursive $ref")
268
+ if "allOf" in node and len(node["allOf"]) == 1:
269
+ inner = _resolve(node["allOf"][0], root)
270
+ node = {**inner, **{k: v for k, v in node.items() if k != "allOf"}}
271
+ if "allOf" in node:
272
+ raise Ineligible("allOf with several schemas")
273
+ return node
274
+
275
+
276
+ def _type(node: Mapping[str, Any]) -> Any:
277
+ return node.get("type")
278
+
279
+
280
+ def _instructions(node: Mapping[str, Any], path: Path, title: str | None) -> str:
281
+ output = title or "structured output"
282
+ subject = f"the `{'.'.join(path)}` field of the {output} the conversation asks for"
283
+ description = node.get("description") or _humanize(path[-1])
284
+ return f"Decide {subject}: {description}"
285
+
286
+
287
+ def _humanize(name: str) -> str:
288
+ return re.sub(r"(?<=[a-z0-9])(?=[A-Z])|_+", " ", name).strip().lower()
289
+
290
+
291
+ def _question_id(index: int, path: Path) -> str:
292
+ return f"f{index}_" + re.sub(r"[^A-Za-z0-9_]", "_", "__".join(path))[:60]
293
+
294
+
295
+ def _put(output: dict[str, Any], path: Path, value: Any) -> None:
296
+ for key in path[:-1]:
297
+ output = output.setdefault(key, {})
298
+ output[path[-1]] = value
@@ -0,0 +1,126 @@
1
+ """Turn shadow-mode routes into the numbers you need to pick ``min_confidence``.
2
+
3
+ In shadow mode both models answer every call; each route records the fast model's confidence
4
+ and whether the two agreed. ``ShadowReport`` answers "if ``min_confidence`` were t, how much
5
+ traffic would the fast model take, and how often would it agree with the slow one?", and
6
+ suggests the lowest value that keeps agreement where you want it.
7
+ """
8
+
9
+ from collections.abc import Iterable, Sequence
10
+ from dataclasses import dataclass
11
+
12
+ from .lazy import Route
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class Point:
17
+ """What the fast model would take at one ``min_confidence``."""
18
+
19
+ min_confidence: float
20
+ taken: int
21
+ """Calls whose confidence reaches ``min_confidence``."""
22
+ agreed: int
23
+ """Of those, the ones where the fast and slow answers matched."""
24
+ total: int
25
+ """All shadow calls in the report."""
26
+
27
+ @property
28
+ def coverage(self) -> float:
29
+ """Share of all calls the fast model would answer."""
30
+ return self.taken / self.total if self.total else 0.0
31
+
32
+ @property
33
+ def agreement(self) -> float | None:
34
+ """Share of the taken calls where the two models agreed; None if none are taken."""
35
+ return self.agreed / self.taken if self.taken else None
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class Band:
40
+ """Calls whose confidence falls in ``[low, high)``."""
41
+
42
+ low: float
43
+ high: float
44
+ count: int
45
+ agreed: int
46
+
47
+ @property
48
+ def agreement(self) -> float | None:
49
+ return self.agreed / self.count if self.count else None
50
+
51
+
52
+ _GRID = tuple(round(i * 0.05, 2) for i in range(21)) # 0.00, 0.05, ..., 1.00
53
+
54
+
55
+ class ShadowReport:
56
+ """Agreement between the fast and slow models, by confidence, from shadow-mode routes.
57
+
58
+ Routes that aren't from shadow mode (or have no confidence) are ignored, so the list
59
+ collected by ``on_route`` can be passed as is.
60
+ """
61
+
62
+ def __init__(self, routes: Iterable[Route]) -> None:
63
+ self._calls = tuple(
64
+ (r.confidence, not r.shadow_mismatches)
65
+ for r in routes
66
+ if r.reason == "shadow" and r.confidence is not None
67
+ )
68
+
69
+ def __len__(self) -> int:
70
+ return len(self._calls)
71
+
72
+ def at(self, min_confidence: float) -> Point:
73
+ taken = [agreed for confidence, agreed in self._calls if confidence >= min_confidence]
74
+ return Point(min_confidence, len(taken), sum(taken), len(self._calls))
75
+
76
+ def curve(self, values: Sequence[float] = _GRID) -> list[Point]:
77
+ """One point per ``min_confidence`` value, by default every 0.05 from 0 to 1. Ready to
78
+ plot."""
79
+ return [self.at(v) for v in values]
80
+
81
+ def bands(self, edges: Sequence[float] = (0.0, 0.7, 0.9, 1.0)) -> list[Band]:
82
+ """Calls grouped by confidence, highest band first; the top band includes 1.0."""
83
+ bands = []
84
+ for low, high in zip(edges, edges[1:], strict=False):
85
+ top = high >= edges[-1]
86
+ inside = [a for c, a in self._calls if low <= c and (c <= high if top else c < high)]
87
+ bands.append(Band(low, high, len(inside), sum(inside)))
88
+ return bands[::-1]
89
+
90
+ def suggest_min_confidence(
91
+ self, min_agreement: float, *, min_samples: int = 20
92
+ ) -> float | None:
93
+ """The lowest ``min_confidence`` at which agreement stays at or above ``min_agreement``.
94
+
95
+ "Stays" means at that value and at every higher one that still covers at least
96
+ ``min_samples`` calls, so the answer doesn't rest on a lucky dip. Candidates are the
97
+ observed confidences. Returns None when no value covers ``min_samples`` calls with
98
+ enough agreement: collect more shadow traffic, or lower your bar.
99
+ """
100
+ if not 0.0 <= min_agreement <= 1.0:
101
+ raise ValueError("min_agreement must be between 0 and 1")
102
+ points = [self.at(c) for c in sorted({c for c, _ in self._calls})]
103
+ points = [p for p in points if p.taken >= min_samples]
104
+ best = None
105
+ for point in reversed(points): # from the highest value down
106
+ if point.agreement < min_agreement:
107
+ break
108
+ best = point.min_confidence
109
+ return best
110
+
111
+ def table(self, values: Sequence[float] = (0.5, 0.6, 0.7, 0.8, 0.9)) -> str:
112
+ """A plain-text summary: agreement by confidence band, then by ``min_confidence``."""
113
+ lines = [f"{len(self)} shadow calls", "", "confidence band calls agreement"]
114
+ for band in self.bands():
115
+ agreement = _ratio(band.agreed, band.count)
116
+ lines.append(f" {band.low:.2f}-{band.high:.2f} {band.count:>5} {agreement}")
117
+ lines += ["", "min confidence fast takes agreement"]
118
+ for p in self.curve(values):
119
+ share = f"{p.taken}/{p.total} ({p.coverage:.0%})"
120
+ agreement = _ratio(p.agreed, p.taken)
121
+ lines.append(f" {p.min_confidence:.2f} {share:<14} {agreement}")
122
+ return "\n".join(lines)
123
+
124
+
125
+ def _ratio(part: int, whole: int) -> str:
126
+ return f"{part}/{whole} ({part / whole:.0%})" if whole else "-"