needlepath 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.
needlepath/result.py ADDED
@@ -0,0 +1,302 @@
1
+ """What a selection call returns, including every way it declines to apply.
2
+
3
+ The central type is :class:`SelectionResult`. It is what makes "fail open" a
4
+ *type*, not a convention: there is no success path that returns a response and a
5
+ failure path that raises, so an integration cannot accidentally be written to
6
+ apply a selection that did not happen.
7
+
8
+ ``applied`` is the only thing an adapter needs to read. When it is ``False`` the
9
+ original context passes through unmodified — always, for every value of
10
+ ``reason``, including the ones that are not errors at all.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import dataclasses
16
+ from dataclasses import dataclass, field
17
+ from typing import Any, Dict, List, Optional
18
+
19
+ from .models import ContextResponse
20
+
21
+
22
+ class Reason:
23
+ """Why a result was, or was not, applied. **An open enum.**
24
+
25
+ Treat an unrecognized value the way you treat ``PASSTHROUGH_*``: do not
26
+ apply. New reasons are added additively as the service grows outcomes, and
27
+ a caller that exhaustively matches this set is a caller that breaks.
28
+ """
29
+
30
+ #: The selection is safe to apply.
31
+ OK = "ok"
32
+
33
+ # --- not errors: the service or the caller asked us not to apply --------
34
+ #: Shadow mode. A real call was made and is reported; nothing is applied.
35
+ SHADOW = "shadow"
36
+ #: The integration was constructed disabled (``enabled=False``).
37
+ DISABLED = "disabled"
38
+ #: There was nothing to select over — no call was made.
39
+ NO_RECORDS = "no_records"
40
+ #: Below the configured trigger; selecting would not pay. No call was made.
41
+ BELOW_TRIGGER = "below_trigger"
42
+ #: The seam does not carry a shape this adapter can select over safely.
43
+ #: (LiteLLM's non-chat call types, an empty message list, a system-only
44
+ #: history.) Not an error — a scope boundary.
45
+ UNSUPPORTED = "unsupported"
46
+ #: The service returned a selection that would not have made the context
47
+ #: smaller. Applying it would cost a rewrite and buy nothing.
48
+ NO_SAVING = "no_saving"
49
+
50
+ # --- the service ran and declined ---------------------------------------
51
+ #: Zero records selected. NEXPE-413's precedent: an empty selection is a
52
+ #: passthrough at a framework boundary, never an empty context.
53
+ EMPTY_SELECTION = "empty_selection"
54
+ #: ``fallback_used`` — the engine stood down and wants the full context.
55
+ ENGINE_FALLBACK = "engine_fallback"
56
+ #: The response carried a ``selection_error``.
57
+ SELECTION_ERROR = "selection_error"
58
+ #: ``return_per_record`` detail was required by the caller and absent.
59
+ NO_PER_RECORD_DETAIL = "no_per_record_detail"
60
+ #: The response selected records, but **none of them are ones we sent**.
61
+ #: Applying it would treat every record the caller gave us as unselected —
62
+ #: i.e. collapse the entire context. A passthrough, not a selection.
63
+ UNKNOWN_SELECTION = "unknown_selection"
64
+ #: The coverage verdict says the selection is not safe to use
65
+ #: (``safety.fallback_required``, or ``selection_safe`` false).
66
+ SELECTION_UNSAFE = "selection_unsafe"
67
+
68
+ # --- error paths ---------------------------------------------------------
69
+ #: The serialized body exceeded the client-side size guard. No call made.
70
+ PAYLOAD_TOO_LARGE = "payload_too_large"
71
+ #: The deadline elapsed.
72
+ TIMEOUT = "timeout"
73
+ #: No HTTP response at all (DNS, connect, reset, TLS).
74
+ TRANSPORT_ERROR = "transport_error"
75
+ #: A non-2xx response.
76
+ HTTP_ERROR = "http_error"
77
+ #: A 2xx body that violates the response contract.
78
+ CONTRACT_ERROR = "contract_error"
79
+ #: A defect in this client. Recorded, never raised on the framework path.
80
+ UNEXPECTED_ERROR = "unexpected_error"
81
+
82
+
83
+ #: The reasons that are *not* a failure of anything. Useful for deciding
84
+ #: whether a warning deserves a log line at WARNING or at DEBUG.
85
+ BENIGN_REASONS = frozenset(
86
+ {
87
+ Reason.OK,
88
+ Reason.SHADOW,
89
+ Reason.DISABLED,
90
+ Reason.NO_RECORDS,
91
+ Reason.BELOW_TRIGGER,
92
+ Reason.UNSUPPORTED,
93
+ Reason.NO_SAVING,
94
+ Reason.EMPTY_SELECTION,
95
+ Reason.ENGINE_FALLBACK,
96
+ Reason.SELECTION_UNSAFE,
97
+ }
98
+ )
99
+
100
+
101
+ @dataclass(frozen=True)
102
+ class SelectionResult:
103
+ """The outcome of one selection call. Never raises; always answerable."""
104
+
105
+ applied: bool
106
+ reason: str
107
+ request_id: str
108
+ operating_point: str
109
+ shadow: bool = False
110
+ response: Optional[ContextResponse] = None
111
+ error: Optional[BaseException] = None
112
+ client_latency_ms: float = 0.0
113
+ attempts: int = 0
114
+ #: Bytes actually put on the wire, or that would have been. Zero when no
115
+ #: request was built.
116
+ request_bytes: int = 0
117
+
118
+ # ------------------------------------------------------------------ #
119
+
120
+ @property
121
+ def passthrough(self) -> bool:
122
+ """``True`` when the caller must use its original context unmodified."""
123
+ return not self.applied
124
+
125
+ @property
126
+ def failed(self) -> bool:
127
+ """``True`` when something actually went wrong, as opposed to declined."""
128
+ return self.reason not in BENIGN_REASONS
129
+
130
+ @property
131
+ def error_type(self) -> Optional[str]:
132
+ return type(self.error).__name__ if self.error is not None else None
133
+
134
+ @property
135
+ def rendered_context(self) -> str:
136
+ """The model-facing block, or ``""`` when nothing is applicable."""
137
+ return self.response.rendered_context if self.response is not None else ""
138
+
139
+ @property
140
+ def selected_ids(self) -> List[str]:
141
+ if self.response is None:
142
+ return []
143
+ return [s.record_id for s in self.response.selected]
144
+
145
+ @property
146
+ def excerpts(self) -> Dict[str, str]:
147
+ """``record_id`` → excerpt. Empty unless per-record detail came back."""
148
+ return self.response.excerpts if self.response is not None else {}
149
+
150
+ @property
151
+ def tokens_saved(self) -> int:
152
+ return self.response.tokens_saved if self.response is not None else 0
153
+
154
+ # ------------------------------------------------------------------ #
155
+
156
+ def metadata(self, prefix: str = "needlepath") -> Dict[str, Any]:
157
+ """A flat, **metadata-only** dict to attach to a framework's metadata.
158
+
159
+ §8.2 asks every adapter to "emit a metadata-only warning" when it fails
160
+ open. This is that warning, in a shape that survives being dropped into
161
+ a ``ToolMessage.response_metadata``, a LiteLLM ``metadata`` blob or a
162
+ LlamaIndex node's ``metadata``.
163
+
164
+ **Nothing derived from record text is in here**, and that is a rule with
165
+ one non-obvious consequence: ``selection_error`` is built server-side as
166
+ ``f"{type(exc).__name__}: {exc}"`` and can therefore carry fragments of
167
+ the input in an exception message — the Lambda deliberately declines to
168
+ log it for exactly this reason. So only its *type* travels here. The
169
+ full string remains on ``result.response.selection_error`` for a caller
170
+ who wants it and knows what they are handling.
171
+ """
172
+ out: Dict[str, Any] = {
173
+ "applied": self.applied,
174
+ "reason": self.reason,
175
+ "shadow": self.shadow,
176
+ "request_id": self.request_id,
177
+ "operating_point": self.operating_point,
178
+ "client_latency_ms": round(self.client_latency_ms, 3),
179
+ "attempts": self.attempts,
180
+ "request_bytes": self.request_bytes,
181
+ }
182
+ if self.error is not None:
183
+ out["error_type"] = type(self.error).__name__
184
+ status = getattr(self.error, "status", None)
185
+ if status is not None:
186
+ out["http_status"] = status
187
+ code = getattr(self.error, "error_code", None)
188
+ if code is not None:
189
+ out["error_code"] = code
190
+ r = self.response
191
+ if r is not None:
192
+ out.update(
193
+ {
194
+ "policy_version": r.policy_version,
195
+ "tokens_before": r.tokens_before,
196
+ "tokens_after": r.tokens_after,
197
+ "tokens_saved": r.tokens_saved,
198
+ "reduction_ratio": round(r.reduction_ratio, 6),
199
+ "records_available": r.records_available,
200
+ "records_selected": r.records_selected,
201
+ "fallback_used": r.fallback_used,
202
+ "engine_latency_ms": r.engine_latency_ms,
203
+ }
204
+ )
205
+ if r.selection_error:
206
+ # Type only — see the docstring.
207
+ out["selection_error_type"] = r.selection_error.split(":", 1)[0].strip()
208
+ if r.gate is not None:
209
+ out["gate_engaged"] = r.gate.engaged
210
+ out["gate_reason"] = r.gate.reason
211
+ if not prefix:
212
+ return out
213
+ return {f"{prefix}.{k}": v for k, v in out.items()}
214
+
215
+ # ------------------------------------------------------------------ #
216
+
217
+ def declined(self, reason: str) -> SelectionResult:
218
+ """A copy of this result that the adapter decided not to apply.
219
+
220
+ The service answered; the *adapter* declined — because the rewrite would
221
+ not have saved anything, or because the seam cannot carry the shape
222
+ safely. The response is kept so the metadata still reports what the
223
+ service actually said, which is what a shadow report needs.
224
+ """
225
+ return dataclasses.replace(self, applied=False, reason=reason)
226
+
227
+ @classmethod
228
+ def passthrough_result(
229
+ cls,
230
+ reason: str,
231
+ *,
232
+ request_id: str = "",
233
+ operating_point: str = "",
234
+ shadow: bool = False,
235
+ response: Optional[ContextResponse] = None,
236
+ error: Optional[BaseException] = None,
237
+ client_latency_ms: float = 0.0,
238
+ attempts: int = 0,
239
+ request_bytes: int = 0,
240
+ ) -> SelectionResult:
241
+ return cls(
242
+ applied=False,
243
+ reason=reason,
244
+ request_id=request_id,
245
+ operating_point=operating_point,
246
+ shadow=shadow,
247
+ response=response,
248
+ error=error,
249
+ client_latency_ms=client_latency_ms,
250
+ attempts=attempts,
251
+ request_bytes=request_bytes,
252
+ )
253
+
254
+
255
+ @dataclass
256
+ class SelectionStats:
257
+ """Counters an adapter can expose for a shadow report or a health check.
258
+
259
+ Deliberately tiny and deliberately not global: an adapter instance owns one.
260
+ Thread-safety is left to the caller; every field is a plain int incremented
261
+ under the GIL, so a lost increment under contention costs a count, never
262
+ correctness of the request path.
263
+ """
264
+
265
+ calls: int = 0
266
+ applied: int = 0
267
+ passthrough: int = 0
268
+ failures: int = 0
269
+ tokens_before: int = 0
270
+ tokens_after: int = 0
271
+ tokens_saved: int = 0
272
+ reasons: Dict[str, int] = field(default_factory=dict)
273
+
274
+ def record(self, result: SelectionResult) -> None:
275
+ self.calls += 1
276
+ self.reasons[result.reason] = self.reasons.get(result.reason, 0) + 1
277
+ if result.applied:
278
+ self.applied += 1
279
+ else:
280
+ self.passthrough += 1
281
+ if result.failed:
282
+ self.failures += 1
283
+ r = result.response
284
+ if r is not None:
285
+ self.tokens_before += r.tokens_before
286
+ self.tokens_after += r.tokens_after
287
+ self.tokens_saved += r.tokens_saved
288
+
289
+ def as_dict(self) -> Dict[str, Any]:
290
+ return {
291
+ "calls": self.calls,
292
+ "applied": self.applied,
293
+ "passthrough": self.passthrough,
294
+ "failures": self.failures,
295
+ "tokens_before": self.tokens_before,
296
+ "tokens_after": self.tokens_after,
297
+ "tokens_saved": self.tokens_saved,
298
+ "reasons": dict(self.reasons),
299
+ }
300
+
301
+
302
+ __all__ = ["Reason", "BENIGN_REASONS", "SelectionResult", "SelectionStats"]
needlepath/testing.py ADDED
@@ -0,0 +1,224 @@
1
+ """Test doubles, shipped as public API.
2
+
3
+ The framework adapters in this tree test against these rather than against a
4
+ network, and so should yours: every binding rule in §8.2 is a rule about what
5
+ happens when the *service* misbehaves, and you cannot test that against a
6
+ healthy service.
7
+
8
+ Nothing here talks to a network. :class:`FakeTransport` is a scripted
9
+ :class:`~needlepath.transport.Transport`; :func:`response_payload` builds a
10
+ contract-valid response body so a test does not have to hand-write ten required
11
+ fields to exercise one of them.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import Any, Callable, Dict, List, Optional, Sequence, Union
17
+
18
+ from .transport import HttpResult
19
+
20
+ #: Something a :class:`FakeTransport` can be scripted with: a ready result, an
21
+ #: exception to raise, or a callable that produces one from the request.
22
+ Scripted = Union[HttpResult, BaseException, Callable[..., HttpResult]]
23
+
24
+
25
+ def response_payload(
26
+ request_id: str,
27
+ *,
28
+ rendered_context: str = "Relevant State:\n- kept",
29
+ selected: Optional[Sequence[Dict[str, Any]]] = None,
30
+ tokens_before: int = 1000,
31
+ tokens_after: int = 100,
32
+ records_available: int = 4,
33
+ records_selected: int = 1,
34
+ fallback_used: bool = False,
35
+ selection_error: Optional[str] = None,
36
+ policy_version: str = "np-2026-07-r2",
37
+ gate: Optional[Dict[str, Any]] = None,
38
+ engine_latency_ms: float = 12.0,
39
+ **extra: Any,
40
+ ) -> Dict[str, Any]:
41
+ """A contract-valid ``ContextResponse`` body.
42
+
43
+ ``**extra`` lands as unknown top-level fields, which is how you test that
44
+ this client tolerates the additive changes ``/v1`` is allowed to make
45
+ (``usage``, and whatever comes after it).
46
+ """
47
+ if selected is None and records_selected:
48
+ selected = [
49
+ {
50
+ "record_id": "r1",
51
+ "kind": "tool_result",
52
+ "score": 0.9,
53
+ "reason": "engage:needle",
54
+ "excerpt": "kept",
55
+ "excerpt_format": "plain",
56
+ "selected_tokens": 100,
57
+ }
58
+ ]
59
+ payload: Dict[str, Any] = {
60
+ "request_id": request_id,
61
+ "rendered_context": rendered_context,
62
+ "policy_version": policy_version,
63
+ "selected": list(selected or []),
64
+ "tokens_before": tokens_before,
65
+ "tokens_after": tokens_after,
66
+ "tokens_saved": max(0, tokens_before - tokens_after),
67
+ "records_available": records_available,
68
+ "records_selected": records_selected,
69
+ "fallback_used": fallback_used,
70
+ "selection_error": selection_error,
71
+ "engine_latency_ms": engine_latency_ms,
72
+ "budget_tokens": 4000,
73
+ "attempted_budget_tokens": [],
74
+ "reduction_ratio": (
75
+ (tokens_before - tokens_after) / tokens_before if tokens_before else 0.0
76
+ ),
77
+ "safety": None,
78
+ "gate": gate
79
+ if gate is not None
80
+ else {"engaged": True, "reason": "engage:needle", "signals": {}},
81
+ "format_metrics": {"context_format": "plain"},
82
+ }
83
+ payload.update(extra)
84
+ return payload
85
+
86
+
87
+ def ok(payload: Dict[str, Any], status: int = 200) -> HttpResult:
88
+ """A JSON :class:`HttpResult`."""
89
+ import json
90
+
91
+ return HttpResult(
92
+ status=status,
93
+ body=json.dumps(payload).encode("utf-8"),
94
+ headers={"content-type": "application/json"},
95
+ )
96
+
97
+
98
+ def error(status: int, code: str = "engine_error", **fields: Any) -> HttpResult:
99
+ """A JSON error :class:`HttpResult` in the shape the endpoint actually sends."""
100
+ import json
101
+
102
+ body: Dict[str, Any] = {"error": code}
103
+ body.update(fields)
104
+ return HttpResult(
105
+ status=status,
106
+ body=json.dumps(body).encode("utf-8"),
107
+ headers={"content-type": "application/json"},
108
+ )
109
+
110
+
111
+ class FakeTransport:
112
+ """A scripted transport. Records every call it was asked to make.
113
+
114
+ Script entries are consumed in order. When the script runs out, the last
115
+ entry repeats — so ``FakeTransport([timeout_error])`` is "always times out"
116
+ without having to know the retry count.
117
+ """
118
+
119
+ def __init__(self, script: Optional[Sequence[Scripted]] = None) -> None:
120
+ self.script: List[Scripted] = list(script or [])
121
+ self.calls: List[Dict[str, Any]] = []
122
+ self.closed = False
123
+
124
+ # -- sync seam ----------------------------------------------------- #
125
+
126
+ def request(
127
+ self,
128
+ method: str,
129
+ url: str,
130
+ *,
131
+ headers: Dict[str, str],
132
+ body: Optional[bytes],
133
+ timeout: float,
134
+ ) -> HttpResult:
135
+ self.calls.append(
136
+ {
137
+ "method": method,
138
+ "url": url,
139
+ "headers": dict(headers),
140
+ "body": body,
141
+ "timeout": timeout,
142
+ }
143
+ )
144
+ if not self.script:
145
+ raise AssertionError("FakeTransport called with an empty script")
146
+ index = min(len(self.calls) - 1, len(self.script) - 1)
147
+ entry = self.script[index]
148
+ if isinstance(entry, BaseException):
149
+ raise entry
150
+ if callable(entry):
151
+ return entry(method, url, headers=headers, body=body, timeout=timeout)
152
+ return entry
153
+
154
+ def close(self) -> None:
155
+ self.closed = True
156
+
157
+ # -- async seam ---------------------------------------------------- #
158
+
159
+ async def arequest(
160
+ self,
161
+ method: str,
162
+ url: str,
163
+ *,
164
+ headers: Dict[str, str],
165
+ body: Optional[bytes],
166
+ timeout: float,
167
+ ) -> HttpResult:
168
+ return self.request(method, url, headers=headers, body=body, timeout=timeout)
169
+
170
+ async def aclose(self) -> None:
171
+ self.closed = True
172
+
173
+ # -- helpers ------------------------------------------------------- #
174
+
175
+ @property
176
+ def bodies(self) -> List[Dict[str, Any]]:
177
+ """Every request body, parsed. The assertion surface for wire tests."""
178
+ import json
179
+
180
+ return [json.loads(c["body"].decode("utf-8")) for c in self.calls if c["body"]]
181
+
182
+
183
+ class FakeAsyncTransport:
184
+ """The async seam over a :class:`FakeTransport`.
185
+
186
+ Separate class rather than duck-typing the sync one, because
187
+ ``AsyncTransport.request`` must be awaitable and ``Transport.request`` must
188
+ not be; one object cannot satisfy both without a lie somewhere.
189
+ """
190
+
191
+ def __init__(self, script: Optional[Sequence[Scripted]] = None) -> None:
192
+ self.inner = FakeTransport(script)
193
+
194
+ async def request(
195
+ self,
196
+ method: str,
197
+ url: str,
198
+ *,
199
+ headers: Dict[str, str],
200
+ body: Optional[bytes],
201
+ timeout: float,
202
+ ) -> HttpResult:
203
+ return self.inner.request(method, url, headers=headers, body=body, timeout=timeout)
204
+
205
+ async def aclose(self) -> None:
206
+ await self.inner.aclose()
207
+
208
+ @property
209
+ def calls(self) -> List[Dict[str, Any]]:
210
+ return self.inner.calls
211
+
212
+ @property
213
+ def bodies(self) -> List[Dict[str, Any]]:
214
+ return self.inner.bodies
215
+
216
+
217
+ __all__ = [
218
+ "FakeTransport",
219
+ "FakeAsyncTransport",
220
+ "response_payload",
221
+ "ok",
222
+ "error",
223
+ "Scripted",
224
+ ]