needlepath 0.1.1__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.1/CHANGELOG.md +51 -0
  2. {needlepath-0.1.1 → needlepath-0.2.1}/PKG-INFO +43 -7
  3. {needlepath-0.1.1 → needlepath-0.2.1}/README.md +41 -5
  4. {needlepath-0.1.1 → needlepath-0.2.1}/pyproject.toml +1 -1
  5. {needlepath-0.1.1 → needlepath-0.2.1}/src/needlepath/__init__.py +15 -1
  6. {needlepath-0.1.1 → needlepath-0.2.1}/src/needlepath/client.py +303 -36
  7. {needlepath-0.1.1 → needlepath-0.2.1}/src/needlepath/models.py +231 -15
  8. {needlepath-0.1.1 → needlepath-0.2.1}/src/needlepath/result.py +17 -0
  9. {needlepath-0.1.1 → needlepath-0.2.1}/src/needlepath/version.py +1 -1
  10. {needlepath-0.1.1 → needlepath-0.2.1}/tests/test_fail_open.py +90 -0
  11. {needlepath-0.1.1 → 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.1/tests/test_simple_shape.py +251 -0
  14. {needlepath-0.1.1 → needlepath-0.2.1}/tests/test_wire_compatibility.py +147 -25
  15. {needlepath-0.1.1 → needlepath-0.2.1}/.gitignore +0 -0
  16. {needlepath-0.1.1 → needlepath-0.2.1}/LICENSE +0 -0
  17. {needlepath-0.1.1 → needlepath-0.2.1}/src/needlepath/errors.py +0 -0
  18. {needlepath-0.1.1 → needlepath-0.2.1}/src/needlepath/features.py +0 -0
  19. {needlepath-0.1.1 → needlepath-0.2.1}/src/needlepath/py.typed +0 -0
  20. {needlepath-0.1.1 → needlepath-0.2.1}/src/needlepath/testing.py +0 -0
  21. {needlepath-0.1.1 → needlepath-0.2.1}/src/needlepath/transport.py +0 -0
  22. {needlepath-0.1.1 → needlepath-0.2.1}/tests/test_async_parity.py +0 -0
  23. {needlepath-0.1.1 → needlepath-0.2.1}/tests/test_config_and_guards.py +0 -0
  24. {needlepath-0.1.1 → needlepath-0.2.1}/tests/test_no_secret_leak.py +0 -0
  25. {needlepath-0.1.1 → needlepath-0.2.1}/tests/test_retries.py +0 -0
  26. {needlepath-0.1.1 → needlepath-0.2.1}/tests/test_shadow_mode.py +0 -0
  27. {needlepath-0.1.1 → needlepath-0.2.1}/tests/test_urllib_transport.py +0 -0
@@ -0,0 +1,51 @@
1
+ # Changelog
2
+
3
+ All notable changes to the `needlepath` Python package are documented here.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
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
+
14
+ ## [0.2.0] - 2026-08-07
15
+
16
+ ### Added
17
+
18
+ - **Simple text shape** (NEXPE-461): `select(text=...)` accepts a raw string as
19
+ the alternative to `records=`. Exactly one of the two may be passed; passing
20
+ both fails open client-side with a `NeedlepathConfigError` whose message
21
+ mirrors the server's `400 text_and_records_both_set`. The text goes on the
22
+ wire verbatim; the server chunks it, and each chunk comes back in
23
+ `selected[].record_id` as a content-hash id of the form `sha256:<16 hex>`.
24
+ `ContextRequest` gains an optional `text` field, omitted from the wire when
25
+ unset.
26
+
27
+ ### Changed
28
+
29
+ - **`request_id` is now optional** (NEXPE-461): it is a sent-side correlation
30
+ id. `ContextRequest.request_id` may be `None`, in which case the key is
31
+ omitted from the payload and the server assigns its own id (reported on
32
+ `SelectionResult.request_id`). In `select()` / `build_request()`, leaving the
33
+ argument off still auto-generates an id as before; pass `request_id=None`
34
+ explicitly to omit it. The response echo check is skipped when no id was
35
+ sent.
36
+
37
+ ### Note
38
+
39
+ - No breaking changes for existing callers: records requests serialize
40
+ byte-identically to 0.1.x, omitting `request_id` still auto-generates one,
41
+ and `operating_point` remains the required, immutable-label constructor
42
+ argument.
43
+
44
+ ## [0.1.1] - 2026-08-06
45
+
46
+ - Default base URL moved to `https://api.nextmoca.com`; no wire-shape changes.
47
+
48
+ ## [0.1.0] - 2026-08-01
49
+
50
+ - Initial release: fail-open `select()`, pinned operating point, client-side
51
+ 6 MB payload guard, shadow mode, sync and async clients.
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: needlepath
3
- Version: 0.1.1
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.1.1"
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",