needlepath 0.2.0__tar.gz → 0.2.1__tar.gz

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.
Files changed (27) hide show
  1. {needlepath-0.2.0 → needlepath-0.2.1}/CHANGELOG.md +6 -0
  2. {needlepath-0.2.0 → needlepath-0.2.1}/PKG-INFO +43 -7
  3. {needlepath-0.2.0 → needlepath-0.2.1}/README.md +41 -5
  4. {needlepath-0.2.0 → needlepath-0.2.1}/pyproject.toml +1 -1
  5. {needlepath-0.2.0 → needlepath-0.2.1}/src/needlepath/__init__.py +15 -1
  6. {needlepath-0.2.0 → needlepath-0.2.1}/src/needlepath/client.py +197 -9
  7. {needlepath-0.2.0 → needlepath-0.2.1}/src/needlepath/models.py +186 -4
  8. {needlepath-0.2.0 → needlepath-0.2.1}/src/needlepath/result.py +17 -0
  9. {needlepath-0.2.0 → needlepath-0.2.1}/src/needlepath/version.py +1 -1
  10. {needlepath-0.2.0 → needlepath-0.2.1}/tests/test_fail_open.py +90 -0
  11. {needlepath-0.2.0 → needlepath-0.2.1}/tests/test_live_endpoint.py +7 -5
  12. needlepath-0.2.1/tests/test_r4_contract.py +175 -0
  13. {needlepath-0.2.0 → needlepath-0.2.1}/tests/test_wire_compatibility.py +79 -3
  14. {needlepath-0.2.0 → needlepath-0.2.1}/.gitignore +0 -0
  15. {needlepath-0.2.0 → needlepath-0.2.1}/LICENSE +0 -0
  16. {needlepath-0.2.0 → needlepath-0.2.1}/src/needlepath/errors.py +0 -0
  17. {needlepath-0.2.0 → needlepath-0.2.1}/src/needlepath/features.py +0 -0
  18. {needlepath-0.2.0 → needlepath-0.2.1}/src/needlepath/py.typed +0 -0
  19. {needlepath-0.2.0 → needlepath-0.2.1}/src/needlepath/testing.py +0 -0
  20. {needlepath-0.2.0 → needlepath-0.2.1}/src/needlepath/transport.py +0 -0
  21. {needlepath-0.2.0 → needlepath-0.2.1}/tests/test_async_parity.py +0 -0
  22. {needlepath-0.2.0 → needlepath-0.2.1}/tests/test_config_and_guards.py +0 -0
  23. {needlepath-0.2.0 → needlepath-0.2.1}/tests/test_no_secret_leak.py +0 -0
  24. {needlepath-0.2.0 → needlepath-0.2.1}/tests/test_retries.py +0 -0
  25. {needlepath-0.2.0 → needlepath-0.2.1}/tests/test_shadow_mode.py +0 -0
  26. {needlepath-0.2.0 → needlepath-0.2.1}/tests/test_simple_shape.py +0 -0
  27. {needlepath-0.2.0 → needlepath-0.2.1}/tests/test_urllib_transport.py +0 -0
@@ -5,6 +5,12 @@ All notable changes to the `needlepath` Python package are documented here.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.2.1] - 2026-09-03
9
+
10
+ ### Changed
11
+
12
+ - Docs, docstrings and examples pin np-2026-08-r4. No behaviour change.
13
+
8
14
  ## [0.2.0] - 2026-08-07
9
15
 
10
16
  ### Added
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: needlepath
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Framework-free client for the Needlepath hosted context-selection service.
5
5
  Project-URL: Homepage, https://nextmoca.com
6
6
  Author: Next Moca Global, Inc.
@@ -57,7 +57,7 @@ from needlepath import NeedlepathClient, ContextRecord, TaskSpec
57
57
 
58
58
  client = NeedlepathClient(
59
59
  api_key="np_live_...", # or NEEDLEPATH_API_KEY
60
- operating_point="np-2026-07-r2", # required — see "Pin the operating point"
60
+ operating_point="np-2026-08-r4", # required — see "Pin the operating point"
61
61
  )
62
62
 
63
63
  result = client.select(
@@ -80,7 +80,7 @@ original context. That is the whole integration contract.
80
80
  ```python
81
81
  from needlepath import AsyncNeedlepathClient
82
82
 
83
- async with AsyncNeedlepathClient(operating_point="np-2026-07-r2") as client:
83
+ async with AsyncNeedlepathClient(operating_point="np-2026-08-r4") as client:
84
84
  result = await client.select(records=..., task=..., max_context_tokens=4000)
85
85
  ```
86
86
 
@@ -88,6 +88,41 @@ Identical semantics — the decision logic is literally the same functions. Uses
88
88
  `httpx.AsyncClient` when httpx is importable, a worker thread otherwise; it
89
89
  never blocks the event loop either way.
90
90
 
91
+ ### R4 trace and outcome attribution
92
+
93
+ Pin `np-2026-08-r4` and opt into the metadata-only trace when you need to
94
+ inspect record dispositions:
95
+
96
+ ```python
97
+ result = client.select(
98
+ records=records,
99
+ task=TaskSpec(prompt=question),
100
+ max_context_tokens=4000,
101
+ return_trace=True,
102
+ )
103
+
104
+ if result.response and result.response.selection_trace:
105
+ for item in result.response.selection_trace.records:
106
+ print(item.record_id, item.disposition, item.reason)
107
+ ```
108
+
109
+ The hosted response also supplies `result.selection_id`, a server-issued id
110
+ separate from your request id. Report downstream quality after the model call:
111
+
112
+ ```python
113
+ if result.selection_id:
114
+ client.report_outcome(
115
+ result.selection_id,
116
+ task_success=True,
117
+ context_sufficient=True,
118
+ evaluation_source="application",
119
+ )
120
+ ```
121
+
122
+ `report_outcome()` is idempotent and independently retryable. It can raise on a
123
+ callback failure; that never changes `select()` or the context already returned.
124
+ Do not send prompts, context, model output, answers, or free-form notes.
125
+
91
126
  ## Configuration
92
127
 
93
128
  | Argument | Environment | Default |
@@ -130,6 +165,7 @@ if not result.applied:
130
165
  | `disabled` | no | the integration was constructed disabled |
131
166
  | `no_records` | no | nothing to select over; no call was made |
132
167
  | `below_trigger` | no | under the configured trigger; no call was made |
168
+ | `escalated` | no | the engine declined: full context over capacity. **Charged at the full rate**, unlike a free stand-down |
133
169
  | `empty_selection` | no | zero records selected |
134
170
  | `engine_fallback` | no | `fallback_used` — the engine wants the full context |
135
171
  | `selection_error` | no | the response carried a `selection_error` |
@@ -158,7 +194,7 @@ customer absorbs; failing open on "no operating point was pinned" accepts
158
194
  exactly the behaviour drift the rule exists to prevent. It fails at wire-up,
159
195
  never under load.
160
196
 
161
- Labels are immutable: `np-2026-07-r2` means one frozen configuration forever, and
197
+ Labels are immutable: `np-2026-08-r4` means one frozen configuration forever, and
162
198
  a retune mints a new label. `client.operating_points()` lists what the endpoint
163
199
  serves — call it once at wire-up if you want the assertion, never per request.
164
200
 
@@ -176,7 +212,7 @@ that passes at 5.99 MB can still be rejected upstream. Lower
176
212
  ### 4. Shadow mode is a one-line switch
177
213
 
178
214
  ```python
179
- client = NeedlepathClient(operating_point="np-2026-07-r2", shadow=True)
215
+ client = NeedlepathClient(operating_point="np-2026-08-r4", shadow=True)
180
216
  ```
181
217
 
182
218
  The call is made exactly as a live call would be — same body, same billing, same
@@ -228,7 +264,7 @@ string stays on `result.response.selection_error`.
228
264
  from needlepath.testing import FakeTransport, response_payload, ok, error
229
265
 
230
266
  transport = FakeTransport([error(503), ok(response_payload("np-1"))])
231
- client = NeedlepathClient(operating_point="np-2026-07-r2", transport=transport)
267
+ client = NeedlepathClient(operating_point="np-2026-08-r4", transport=transport)
232
268
  ```
233
269
 
234
270
  `needlepath.testing` is public API, not a private helper. Every rule above is a
@@ -28,7 +28,7 @@ from needlepath import NeedlepathClient, ContextRecord, TaskSpec
28
28
 
29
29
  client = NeedlepathClient(
30
30
  api_key="np_live_...", # or NEEDLEPATH_API_KEY
31
- operating_point="np-2026-07-r2", # required — see "Pin the operating point"
31
+ operating_point="np-2026-08-r4", # required — see "Pin the operating point"
32
32
  )
33
33
 
34
34
  result = client.select(
@@ -51,7 +51,7 @@ original context. That is the whole integration contract.
51
51
  ```python
52
52
  from needlepath import AsyncNeedlepathClient
53
53
 
54
- async with AsyncNeedlepathClient(operating_point="np-2026-07-r2") as client:
54
+ async with AsyncNeedlepathClient(operating_point="np-2026-08-r4") as client:
55
55
  result = await client.select(records=..., task=..., max_context_tokens=4000)
56
56
  ```
57
57
 
@@ -59,6 +59,41 @@ Identical semantics — the decision logic is literally the same functions. Uses
59
59
  `httpx.AsyncClient` when httpx is importable, a worker thread otherwise; it
60
60
  never blocks the event loop either way.
61
61
 
62
+ ### R4 trace and outcome attribution
63
+
64
+ Pin `np-2026-08-r4` and opt into the metadata-only trace when you need to
65
+ inspect record dispositions:
66
+
67
+ ```python
68
+ result = client.select(
69
+ records=records,
70
+ task=TaskSpec(prompt=question),
71
+ max_context_tokens=4000,
72
+ return_trace=True,
73
+ )
74
+
75
+ if result.response and result.response.selection_trace:
76
+ for item in result.response.selection_trace.records:
77
+ print(item.record_id, item.disposition, item.reason)
78
+ ```
79
+
80
+ The hosted response also supplies `result.selection_id`, a server-issued id
81
+ separate from your request id. Report downstream quality after the model call:
82
+
83
+ ```python
84
+ if result.selection_id:
85
+ client.report_outcome(
86
+ result.selection_id,
87
+ task_success=True,
88
+ context_sufficient=True,
89
+ evaluation_source="application",
90
+ )
91
+ ```
92
+
93
+ `report_outcome()` is idempotent and independently retryable. It can raise on a
94
+ callback failure; that never changes `select()` or the context already returned.
95
+ Do not send prompts, context, model output, answers, or free-form notes.
96
+
62
97
  ## Configuration
63
98
 
64
99
  | Argument | Environment | Default |
@@ -101,6 +136,7 @@ if not result.applied:
101
136
  | `disabled` | no | the integration was constructed disabled |
102
137
  | `no_records` | no | nothing to select over; no call was made |
103
138
  | `below_trigger` | no | under the configured trigger; no call was made |
139
+ | `escalated` | no | the engine declined: full context over capacity. **Charged at the full rate**, unlike a free stand-down |
104
140
  | `empty_selection` | no | zero records selected |
105
141
  | `engine_fallback` | no | `fallback_used` — the engine wants the full context |
106
142
  | `selection_error` | no | the response carried a `selection_error` |
@@ -129,7 +165,7 @@ customer absorbs; failing open on "no operating point was pinned" accepts
129
165
  exactly the behaviour drift the rule exists to prevent. It fails at wire-up,
130
166
  never under load.
131
167
 
132
- Labels are immutable: `np-2026-07-r2` means one frozen configuration forever, and
168
+ Labels are immutable: `np-2026-08-r4` means one frozen configuration forever, and
133
169
  a retune mints a new label. `client.operating_points()` lists what the endpoint
134
170
  serves — call it once at wire-up if you want the assertion, never per request.
135
171
 
@@ -147,7 +183,7 @@ that passes at 5.99 MB can still be rejected upstream. Lower
147
183
  ### 4. Shadow mode is a one-line switch
148
184
 
149
185
  ```python
150
- client = NeedlepathClient(operating_point="np-2026-07-r2", shadow=True)
186
+ client = NeedlepathClient(operating_point="np-2026-08-r4", shadow=True)
151
187
  ```
152
188
 
153
189
  The call is made exactly as a live call would be — same body, same billing, same
@@ -199,7 +235,7 @@ string stays on `result.response.selection_error`.
199
235
  from needlepath.testing import FakeTransport, response_payload, ok, error
200
236
 
201
237
  transport = FakeTransport([error(503), ok(response_payload("np-1"))])
202
- client = NeedlepathClient(operating_point="np-2026-07-r2", transport=transport)
238
+ client = NeedlepathClient(operating_point="np-2026-08-r4", transport=transport)
203
239
  ```
204
240
 
205
241
  `needlepath.testing` is public API, not a private helper. Every rule above is a
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "needlepath"
7
- version = "0.2.0"
7
+ version = "0.2.1"
8
8
  description = "Framework-free client for the Needlepath hosted context-selection service."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -2,7 +2,7 @@
2
2
 
3
3
  from needlepath import NeedlepathClient, ContextRecord, TaskSpec
4
4
 
5
- client = NeedlepathClient(operating_point="np-2026-07-r2") # key from env
5
+ client = NeedlepathClient(operating_point="np-2026-08-r4") # key from env
6
6
  result = client.select(
7
7
  records=[ContextRecord(text=tool_output, kind="tool_result", id="t1")],
8
8
  task=TaskSpec(prompt=user_question),
@@ -33,10 +33,13 @@ from .client import (
33
33
  HEALTH_PATH,
34
34
  MAX_REQUEST_BYTES,
35
35
  OPERATING_POINTS_PATH,
36
+ OUTCOME_RETRY_STATUSES,
37
+ OUTCOMES_PATH,
36
38
  RETRY_STATUSES,
37
39
  SELECT_PATH,
38
40
  AsyncNeedlepathClient,
39
41
  NeedlepathClient,
42
+ build_outcome_report,
40
43
  build_request,
41
44
  classify,
42
45
  encode_body,
@@ -61,9 +64,13 @@ from .models import (
61
64
  ContextRequest,
62
65
  ContextResponse,
63
66
  GateSummary,
67
+ OutcomeReport,
68
+ OutcomeResponse,
64
69
  RecordKind,
65
70
  SafetySummary,
66
71
  SelectedRecord,
72
+ SelectionTrace,
73
+ SelectionTraceRecord,
67
74
  TaskSpec,
68
75
  )
69
76
  from .result import BENIGN_REASONS, Reason, SelectionResult, SelectionStats
@@ -93,6 +100,10 @@ __all__ = [
93
100
  "SelectedRecord",
94
101
  "SafetySummary",
95
102
  "GateSummary",
103
+ "SelectionTrace",
104
+ "SelectionTraceRecord",
105
+ "OutcomeReport",
106
+ "OutcomeResponse",
96
107
  "RecordKind",
97
108
  "REQUIRED_RESPONSE_FIELDS",
98
109
  # results
@@ -118,6 +129,7 @@ __all__ = [
118
129
  "default_async_transport",
119
130
  # helpers
120
131
  "build_request",
132
+ "build_outcome_report",
121
133
  "encode_body",
122
134
  "classify",
123
135
  "validate_response_payload",
@@ -125,12 +137,14 @@ __all__ = [
125
137
  # constants
126
138
  "DEFAULT_BASE_URL",
127
139
  "SELECT_PATH",
140
+ "OUTCOMES_PATH",
128
141
  "HEALTH_PATH",
129
142
  "OPERATING_POINTS_PATH",
130
143
  "MAX_REQUEST_BYTES",
131
144
  "DEFAULT_TIMEOUT_S",
132
145
  "DEFAULT_MAX_RETRIES",
133
146
  "RETRY_STATUSES",
147
+ "OUTCOME_RETRY_STATUSES",
134
148
  # feature flags
135
149
  "WIRE_SHADOW_MODE",
136
150
  "wire_shadow_mode_enabled",
@@ -42,6 +42,8 @@ from .models import (
42
42
  ContextRecord,
43
43
  ContextRequest,
44
44
  ContextResponse,
45
+ OutcomeReport,
46
+ OutcomeResponse,
45
47
  TaskSpec,
46
48
  )
47
49
  from .result import Reason, SelectionResult
@@ -65,6 +67,7 @@ from .version import __version__
65
67
  DEFAULT_BASE_URL = "https://api.nextmoca.com"
66
68
 
67
69
  SELECT_PATH = "/v1/context/select"
70
+ OUTCOMES_PATH = "/v1/outcomes"
68
71
  HEALTH_PATH = "/v1/health"
69
72
  OPERATING_POINTS_PATH = "/v1/operating-points"
70
73
 
@@ -86,6 +89,7 @@ DEFAULT_MAX_RETRIES = 2
86
89
  #: Retried. Every one of these is a transient condition where a second attempt
87
90
  #: has a real chance of a different answer.
88
91
  RETRY_STATUSES = frozenset({429, 502, 503, 504})
92
+ OUTCOME_RETRY_STATUSES = RETRY_STATUSES | {500}
89
93
 
90
94
  #: **Not** retried, and worth saying why: a 500 from this endpoint is
91
95
  #: ``engine_error`` — a deterministic failure on this exact input. Retrying it
@@ -129,7 +133,7 @@ def _resolve_operating_point(operating_point: Optional[str]) -> str:
129
133
  if not value or not value.strip():
130
134
  raise NeedlepathConfigError(
131
135
  "operating_point is required. Pin an immutable label explicitly "
132
- "(e.g. 'np-2026-07-r2') so behaviour never shifts under you when the "
136
+ "(e.g. 'np-2026-08-r4') so behaviour never shifts under you when the "
133
137
  "service default moves; see docs/needlepath/API_VERSIONING.md. "
134
138
  f"Pass operating_point=... or set {ENV_OPERATING_POINT}."
135
139
  )
@@ -185,6 +189,9 @@ def build_request(
185
189
  render: bool = True,
186
190
  render_format: str = "plain",
187
191
  return_per_record: bool = True,
192
+ return_trace: bool = False,
193
+ selection_strategy: Optional[str] = None,
194
+ support_contract: Optional[str] = None,
188
195
  ) -> ContextRequest:
189
196
  """Assemble a :class:`ContextRequest` with the operating point already pinned.
190
197
 
@@ -223,6 +230,9 @@ def build_request(
223
230
  render=render,
224
231
  render_format=render_format,
225
232
  return_per_record=return_per_record,
233
+ return_trace=return_trace,
234
+ selection_strategy=selection_strategy,
235
+ support_contract=support_contract,
226
236
  )
227
237
 
228
238
 
@@ -270,6 +280,52 @@ def validate_response_payload(payload: Any, request_id: Optional[str]) -> Contex
270
280
  raise NeedlepathContractError(f"malformed response body: {exc}") from exc
271
281
 
272
282
 
283
+ def _header(headers: Dict[str, str], name: str) -> Optional[str]:
284
+ wanted = name.lower()
285
+ for key, value in headers.items():
286
+ if key.lower() == wanted and value:
287
+ return value
288
+ return None
289
+
290
+
291
+ def build_outcome_report(
292
+ selection_id: str,
293
+ *,
294
+ task_success: Optional[bool] = None,
295
+ context_sufficient: Optional[bool] = None,
296
+ evaluation_source: str = "application",
297
+ task_latency_ms: Optional[int] = None,
298
+ baseline_task_latency_ms: Optional[int] = None,
299
+ ) -> OutcomeReport:
300
+ try:
301
+ return OutcomeReport(
302
+ selection_id=selection_id,
303
+ task_success=task_success,
304
+ context_sufficient=context_sufficient,
305
+ evaluation_source=evaluation_source,
306
+ task_latency_ms=task_latency_ms,
307
+ baseline_task_latency_ms=baseline_task_latency_ms,
308
+ )
309
+ except ValueError as exc:
310
+ raise NeedlepathConfigError(str(exc)) from exc
311
+
312
+
313
+ def encode_outcome_body(report: OutcomeReport) -> bytes:
314
+ return json.dumps(report.to_wire(), separators=(",", ":"), ensure_ascii=False).encode("utf-8")
315
+
316
+
317
+ def validate_outcome_payload(payload: Any, selection_id: str) -> OutcomeResponse:
318
+ if not isinstance(payload, dict):
319
+ raise NeedlepathContractError("outcome response is not a JSON object")
320
+ try:
321
+ response = OutcomeResponse.from_wire(payload, expected_selection_id=selection_id)
322
+ except (TypeError, ValueError) as exc:
323
+ raise NeedlepathContractError(f"malformed outcome response: {exc}") from exc
324
+ if not response.accepted:
325
+ raise NeedlepathContractError("outcome response did not acknowledge acceptance")
326
+ return response
327
+
328
+
273
329
  def classify(
274
330
  response: ContextResponse,
275
331
  *,
@@ -285,9 +341,10 @@ def classify(
285
341
  the service can say "I did not select anything useful".
286
342
 
287
343
  Note what this function does **not** do: it never reads ``gate.reason``.
288
- That value is an open enum with a planned third outcome, and a client that
289
- branches on today's prefixes is a client that breaks additively. Every
290
- decision here comes from a structural field.
344
+ That value is an open enum, and a client that branches on today's prefixes
345
+ is a client that breaks additively. Every decision here comes from a
346
+ structural field, plus one recognised value of the top-level ``outcome``
347
+ (see the note at that branch for why that is not the same mistake).
291
348
  """
292
349
  if response.selection_error:
293
350
  return Reason.SELECTION_ERROR
@@ -295,6 +352,15 @@ def classify(
295
352
  # The engine stood down and is asking for the full context. Applying a
296
353
  # fallback render would at best be a no-op and at worst a lossy one.
297
354
  return Reason.ENGINE_FALLBACK
355
+ if response.outcome == "escalated":
356
+ # The one service-supplied enum this function reads, and it reads
357
+ # exactly one value from it. This is not the exhaustive matching the
358
+ # docstring forbids: any value we do not recognise falls through to the
359
+ # structural checks and gets the same answer it got before the field
360
+ # existed. The branch exists because escalation and a stand-down are
361
+ # identical on the wire apart from this field, and they are billed
362
+ # differently: escalation at the full rate, a stand-down free.
363
+ return Reason.ESCALATED
298
364
  safety = response.safety
299
365
  if safety is not None and (safety.fallback_required or not safety.selection_safe):
300
366
  # The coverage verdict is a structural field, not the open enum: it says
@@ -449,7 +515,7 @@ class NeedlepathClient(_ClientBase):
449
515
 
450
516
  client = NeedlepathClient(
451
517
  api_key="np_live_...", # or NEEDLEPATH_API_KEY
452
- operating_point="np-2026-07-r2", # required, and immutable
518
+ operating_point="np-2026-08-r4", # required, and immutable
453
519
  )
454
520
  result = client.select(
455
521
  records=[ContextRecord(text=..., kind="tool_result", id="r1")],
@@ -464,7 +530,7 @@ class NeedlepathClient(_ClientBase):
464
530
  :param api_key: ``np_live_…``. Falls back to ``NEEDLEPATH_API_KEY``. Pass
465
531
  ``""`` to send no ``Authorization`` header (the localhost adapter).
466
532
  :param operating_point: **Required.** An immutable label such as
467
- ``np-2026-07-r2``. Falls back to ``NEEDLEPATH_OPERATING_POINT``.
533
+ ``np-2026-08-r4``. Falls back to ``NEEDLEPATH_OPERATING_POINT``.
468
534
  :param base_url: Defaults to :data:`DEFAULT_BASE_URL` or ``NEEDLEPATH_BASE_URL``.
469
535
  :param timeout: Total wall-clock deadline for one :meth:`select`, retries
470
536
  and backoff included. Not a per-attempt timeout.
@@ -644,6 +710,7 @@ class NeedlepathClient(_ClientBase):
644
710
  "a response arrived after the deadline had elapsed"
645
711
  )
646
712
  response = validate_response_payload(http.json(), request.request_id)
713
+ response.selection_id = _header(http.headers, "x-needlepath-selection-id")
647
714
  return response, attempts, len(body)
648
715
  last = err
649
716
  if http.status not in RETRY_STATUSES:
@@ -670,6 +737,67 @@ class NeedlepathClient(_ClientBase):
670
737
  last._np_bytes = len(body) # type: ignore[attr-defined]
671
738
  raise last
672
739
 
740
+ def report_outcome(
741
+ self,
742
+ selection_id: str,
743
+ *,
744
+ task_success: Optional[bool] = None,
745
+ context_sufficient: Optional[bool] = None,
746
+ evaluation_source: str = "application",
747
+ task_latency_ms: Optional[int] = None,
748
+ baseline_task_latency_ms: Optional[int] = None,
749
+ timeout: Optional[float] = None,
750
+ ) -> OutcomeResponse:
751
+ """Report downstream quality for a served selection.
752
+
753
+ This call is intentionally separate from :meth:`select`: it is
754
+ idempotent and retryable, and any exception here cannot alter the
755
+ selection result already returned to the caller.
756
+ """
757
+ report = build_outcome_report(
758
+ selection_id,
759
+ task_success=task_success,
760
+ context_sufficient=context_sufficient,
761
+ evaluation_source=evaluation_source,
762
+ task_latency_ms=task_latency_ms,
763
+ baseline_task_latency_ms=baseline_task_latency_ms,
764
+ )
765
+ body = encode_outcome_body(report)
766
+ if len(body) > self.max_request_bytes:
767
+ raise NeedlepathPayloadTooLargeError(len(body), self.max_request_bytes)
768
+ deadline = time.monotonic() + float(timeout if timeout is not None else self.timeout)
769
+ last: Optional[NeedlepathError] = None
770
+ for attempt in range(self.max_retries + 1):
771
+ remaining = deadline - time.monotonic()
772
+ if remaining <= 0:
773
+ break
774
+ try:
775
+ http = self._transport.request(
776
+ "POST",
777
+ self._url(OUTCOMES_PATH),
778
+ headers=self._headers(),
779
+ body=body,
780
+ timeout=remaining,
781
+ )
782
+ err = self._to_error(http)
783
+ if err is None:
784
+ return validate_outcome_payload(http.json(), selection_id)
785
+ last = err
786
+ if http.status not in OUTCOME_RETRY_STATUSES:
787
+ break
788
+ retry_after = err.retry_after
789
+ except (NeedlepathTransportError, NeedlepathTimeoutError) as exc:
790
+ last = exc
791
+ retry_after = None
792
+ if attempt >= self.max_retries:
793
+ break
794
+ delay = self._backoff.delay(attempt, retry_after, deadline - time.monotonic())
795
+ if delay:
796
+ time.sleep(delay)
797
+ if last is None:
798
+ last = NeedlepathTimeoutError("deadline elapsed before outcome report was sent")
799
+ raise last
800
+
673
801
  # ---------------------------------------------------------------- #
674
802
  # Diagnostics. These raise: they are not on any agent's hot path.
675
803
  # ---------------------------------------------------------------- #
@@ -873,6 +1001,7 @@ class AsyncNeedlepathClient(_ClientBase):
873
1001
  "a response arrived after the deadline had elapsed"
874
1002
  )
875
1003
  response = validate_response_payload(http.json(), request.request_id)
1004
+ response.selection_id = _header(http.headers, "x-needlepath-selection-id")
876
1005
  return response, attempts, len(body)
877
1006
  last = err
878
1007
  if http.status not in RETRY_STATUSES:
@@ -897,6 +1026,62 @@ class AsyncNeedlepathClient(_ClientBase):
897
1026
  last._np_bytes = len(body) # type: ignore[attr-defined]
898
1027
  raise last
899
1028
 
1029
+ async def report_outcome(
1030
+ self,
1031
+ selection_id: str,
1032
+ *,
1033
+ task_success: Optional[bool] = None,
1034
+ context_sufficient: Optional[bool] = None,
1035
+ evaluation_source: str = "application",
1036
+ task_latency_ms: Optional[int] = None,
1037
+ baseline_task_latency_ms: Optional[int] = None,
1038
+ timeout: Optional[float] = None,
1039
+ ) -> OutcomeResponse:
1040
+ """Asynchronous twin of :meth:`NeedlepathClient.report_outcome`."""
1041
+ report = build_outcome_report(
1042
+ selection_id,
1043
+ task_success=task_success,
1044
+ context_sufficient=context_sufficient,
1045
+ evaluation_source=evaluation_source,
1046
+ task_latency_ms=task_latency_ms,
1047
+ baseline_task_latency_ms=baseline_task_latency_ms,
1048
+ )
1049
+ body = encode_outcome_body(report)
1050
+ if len(body) > self.max_request_bytes:
1051
+ raise NeedlepathPayloadTooLargeError(len(body), self.max_request_bytes)
1052
+ deadline = time.monotonic() + float(timeout if timeout is not None else self.timeout)
1053
+ last: Optional[NeedlepathError] = None
1054
+ for attempt in range(self.max_retries + 1):
1055
+ remaining = deadline - time.monotonic()
1056
+ if remaining <= 0:
1057
+ break
1058
+ try:
1059
+ http = await self._transport.request(
1060
+ "POST",
1061
+ self._url(OUTCOMES_PATH),
1062
+ headers=self._headers(),
1063
+ body=body,
1064
+ timeout=remaining,
1065
+ )
1066
+ err = self._to_error(http)
1067
+ if err is None:
1068
+ return validate_outcome_payload(http.json(), selection_id)
1069
+ last = err
1070
+ if http.status not in OUTCOME_RETRY_STATUSES:
1071
+ break
1072
+ retry_after = err.retry_after
1073
+ except (NeedlepathTransportError, NeedlepathTimeoutError) as exc:
1074
+ last = exc
1075
+ retry_after = None
1076
+ if attempt >= self.max_retries:
1077
+ break
1078
+ delay = self._backoff.delay(attempt, retry_after, deadline - time.monotonic())
1079
+ if delay:
1080
+ await asyncio.sleep(delay)
1081
+ if last is None:
1082
+ last = NeedlepathTimeoutError("deadline elapsed before outcome report was sent")
1083
+ raise last
1084
+
900
1085
  async def health(self, *, timeout: Optional[float] = None) -> Dict[str, Any]:
901
1086
  return await self._aget(HEALTH_PATH, timeout)
902
1087
 
@@ -994,9 +1179,7 @@ def _finish(
994
1179
  reason=reason,
995
1180
  # When the request omitted its sent-side id (NEXPE-461), report the
996
1181
  # server-assigned one so the caller can still correlate with logs.
997
- request_id=(
998
- request.request_id if request.request_id is not None else response.request_id
999
- ),
1182
+ request_id=(request.request_id if request.request_id is not None else response.request_id),
1000
1183
  operating_point=operating_point,
1001
1184
  shadow=shadow,
1002
1185
  response=response,
@@ -1010,12 +1193,14 @@ def _finish(
1010
1193
  __all__ = [
1011
1194
  "DEFAULT_BASE_URL",
1012
1195
  "SELECT_PATH",
1196
+ "OUTCOMES_PATH",
1013
1197
  "HEALTH_PATH",
1014
1198
  "OPERATING_POINTS_PATH",
1015
1199
  "MAX_REQUEST_BYTES",
1016
1200
  "DEFAULT_TIMEOUT_S",
1017
1201
  "DEFAULT_MAX_RETRIES",
1018
1202
  "RETRY_STATUSES",
1203
+ "OUTCOME_RETRY_STATUSES",
1019
1204
  "NeedlepathClient",
1020
1205
  "AsyncNeedlepathClient",
1021
1206
  "build_request",
@@ -1023,4 +1208,7 @@ __all__ = [
1023
1208
  "classify",
1024
1209
  "validate_response_payload",
1025
1210
  "new_request_id",
1211
+ "build_outcome_report",
1212
+ "encode_outcome_body",
1213
+ "validate_outcome_payload",
1026
1214
  ]