hebbrix 2.6.0rc4__tar.gz → 2.6.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.
- {hebbrix-2.6.0rc4 → hebbrix-2.6.1}/CHANGELOG.md +20 -0
- {hebbrix-2.6.0rc4/hebbrix.egg-info → hebbrix-2.6.1}/PKG-INFO +15 -6
- {hebbrix-2.6.0rc4 → hebbrix-2.6.1}/README.md +12 -3
- {hebbrix-2.6.0rc4 → hebbrix-2.6.1}/hebbrix/__init__.py +1 -1
- hebbrix-2.6.1/hebbrix/_advice.py +152 -0
- {hebbrix-2.6.0rc4 → hebbrix-2.6.1}/hebbrix/client.py +1 -1
- {hebbrix-2.6.0rc4 → hebbrix-2.6.1}/hebbrix/resources.py +29 -12
- {hebbrix-2.6.0rc4 → hebbrix-2.6.1}/hebbrix/sync_client.py +29 -12
- {hebbrix-2.6.0rc4 → hebbrix-2.6.1/hebbrix.egg-info}/PKG-INFO +15 -6
- {hebbrix-2.6.0rc4 → hebbrix-2.6.1}/hebbrix.egg-info/SOURCES.txt +2 -0
- {hebbrix-2.6.0rc4 → hebbrix-2.6.1}/hebbrix.egg-info/requires.txt +1 -1
- {hebbrix-2.6.0rc4 → hebbrix-2.6.1}/pyproject.toml +3 -3
- hebbrix-2.6.1/tests/test_advisor_integrity.py +276 -0
- {hebbrix-2.6.0rc4 → hebbrix-2.6.1}/tests/test_outcome_advice_resource.py +20 -0
- hebbrix-2.6.1/tests/test_release_version.py +22 -0
- hebbrix-2.6.0rc4/tests/test_release_version.py +0 -12
- {hebbrix-2.6.0rc4 → hebbrix-2.6.1}/LICENSE +0 -0
- {hebbrix-2.6.0rc4 → hebbrix-2.6.1}/MANIFEST.in +0 -0
- {hebbrix-2.6.0rc4 → hebbrix-2.6.1}/contracts/openapi-routes.json +0 -0
- {hebbrix-2.6.0rc4 → hebbrix-2.6.1}/docs/native-experience-operations.md +0 -0
- {hebbrix-2.6.0rc4 → hebbrix-2.6.1}/examples/native_reflection_worker.py +0 -0
- {hebbrix-2.6.0rc4 → hebbrix-2.6.1}/hebbrix/chat.py +0 -0
- {hebbrix-2.6.0rc4 → hebbrix-2.6.1}/hebbrix/exceptions.py +0 -0
- {hebbrix-2.6.0rc4 → hebbrix-2.6.1}/hebbrix/execution.py +0 -0
- {hebbrix-2.6.0rc4 → hebbrix-2.6.1}/hebbrix/experience.py +0 -0
- {hebbrix-2.6.0rc4 → hebbrix-2.6.1}/hebbrix/models.py +0 -0
- {hebbrix-2.6.0rc4 → hebbrix-2.6.1}/hebbrix/openai_reflection.py +0 -0
- {hebbrix-2.6.0rc4 → hebbrix-2.6.1}/hebbrix/reflection_worker.py +0 -0
- {hebbrix-2.6.0rc4 → hebbrix-2.6.1}/hebbrix/workflow.py +0 -0
- {hebbrix-2.6.0rc4 → hebbrix-2.6.1}/hebbrix.egg-info/dependency_links.txt +0 -0
- {hebbrix-2.6.0rc4 → hebbrix-2.6.1}/hebbrix.egg-info/entry_points.txt +0 -0
- {hebbrix-2.6.0rc4 → hebbrix-2.6.1}/hebbrix.egg-info/top_level.txt +0 -0
- {hebbrix-2.6.0rc4 → hebbrix-2.6.1}/setup.cfg +0 -0
- {hebbrix-2.6.0rc4 → hebbrix-2.6.1}/tests/test_advanced_resources.py +0 -0
- {hebbrix-2.6.0rc4 → hebbrix-2.6.1}/tests/test_evidence_loop_resource.py +0 -0
- {hebbrix-2.6.0rc4 → hebbrix-2.6.1}/tests/test_evidence_safety_boundary.py +0 -0
- {hebbrix-2.6.0rc4 → hebbrix-2.6.1}/tests/test_execution_permission.py +0 -0
- {hebbrix-2.6.0rc4 → hebbrix-2.6.1}/tests/test_experience_request.py +0 -0
- {hebbrix-2.6.0rc4 → hebbrix-2.6.1}/tests/test_experience_workflow.py +0 -0
- {hebbrix-2.6.0rc4 → hebbrix-2.6.1}/tests/test_followup_helpers.py +0 -0
- {hebbrix-2.6.0rc4 → hebbrix-2.6.1}/tests/test_memories_resource.py +0 -0
- {hebbrix-2.6.0rc4 → hebbrix-2.6.1}/tests/test_openai_reflection.py +0 -0
- {hebbrix-2.6.0rc4 → hebbrix-2.6.1}/tests/test_openapi_parity.py +0 -0
- {hebbrix-2.6.0rc4 → hebbrix-2.6.1}/tests/test_procedural_resource.py +0 -0
- {hebbrix-2.6.0rc4 → hebbrix-2.6.1}/tests/test_reflection_worker_cli.py +0 -0
- {hebbrix-2.6.0rc4 → hebbrix-2.6.1}/tests/test_sync_client.py +0 -0
- {hebbrix-2.6.0rc4 → hebbrix-2.6.1}/tests/test_workflow_route_contract.py +0 -0
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 2.6.1 — 2026-10-04 (advisory horizon helpers)
|
|
4
|
+
|
|
5
|
+
- Carry optional remaining_decisions and max_pilot_decisions through evidence reads and the once-only advisor callback.
|
|
6
|
+
- Keep actual caller distribution validation, owner scope and observe-mode logging unchanged.
|
|
7
|
+
- New backend v3 controls remain explicit experimental opt-ins; these helpers do not execute actions or establish performance, calibration or superiority.
|
|
8
|
+
|
|
9
|
+
## 2.6.0 — 2026-10-04 (stable SDK)
|
|
10
|
+
|
|
11
|
+
- Bound runtime HTTPX to `>=0.25.0,<1`, excluding incompatible 1.x prereleases.
|
|
12
|
+
- Add sync/async atomic setup options for explicit policy configuration and built-in success-minus-cost metrics.
|
|
13
|
+
- Keep advisor input/probability validation, scope and protected evidence transports unchanged.
|
|
14
|
+
- Backend v3 learning methods remain explicit experimental opt-ins. Stable packaging does not establish performance, calibrated confidence or execution permission.
|
|
15
|
+
|
|
16
|
+
## 2.6.0rc5 — 2026-10-03 (prerelease)
|
|
17
|
+
|
|
18
|
+
- Snapshot advisor context/candidates and reject scope overrides or mutable identities.
|
|
19
|
+
- Validate the complete finite logging distribution against original candidates before writing.
|
|
20
|
+
- Invoke the callback once; preserve caller probabilities without normalization or retries.
|
|
21
|
+
- Caller probabilities are not authenticated randomization; no execution authority or measured learning-performance gain is claimed.
|
|
22
|
+
|
|
3
23
|
## 2.6.0rc4 — 2026-10-02 (prerelease)
|
|
4
24
|
|
|
5
25
|
- Correct both HTTP User-Agent version strings and add a source-version parity
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: hebbrix
|
|
3
|
-
Version: 2.6.
|
|
3
|
+
Version: 2.6.1
|
|
4
4
|
Summary: Typed Python client for Hebbrix memory, retrieval, and outcome-learning APIs
|
|
5
5
|
Author-email: Hebbrix Team <support@hebbrix.com>
|
|
6
6
|
Maintainer-email: Hebbrix Team <support@hebbrix.com>
|
|
@@ -33,7 +33,7 @@ Project-URL: Issues, https://github.com/Hebbrix/hebbrix-python/issues
|
|
|
33
33
|
Project-URL: Support, https://www.hebbrix.com/contact
|
|
34
34
|
Project-URL: API Reference, https://api.hebbrix.com/docs
|
|
35
35
|
Keywords: ai,memory,agents,llm,chatbot,assistant,ml,reinforcement-learning,knowledge-graph,vector-search,rag,temporal,procedural-memory,working-memory
|
|
36
|
-
Classifier: Development Status ::
|
|
36
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
37
37
|
Classifier: Intended Audience :: Developers
|
|
38
38
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
39
39
|
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
@@ -49,7 +49,7 @@ Classifier: Framework :: AsyncIO
|
|
|
49
49
|
Requires-Python: >=3.8
|
|
50
50
|
Description-Content-Type: text/markdown
|
|
51
51
|
License-File: LICENSE
|
|
52
|
-
Requires-Dist: httpx
|
|
52
|
+
Requires-Dist: httpx<1,>=0.25.0
|
|
53
53
|
Requires-Dist: requests>=2.31.0
|
|
54
54
|
Provides-Extra: dev
|
|
55
55
|
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
@@ -65,15 +65,15 @@ Dynamic: license-file
|
|
|
65
65
|
|
|
66
66
|
Typed Python client for Hebbrix memory, retrieval, and outcome-learning APIs.
|
|
67
67
|
|
|
68
|
-
This branch is the **2.6.
|
|
68
|
+
This branch is the **2.6.0 stable SDK**. The new policy configuration and
|
|
69
69
|
advice helpers require backend schema `e5f6g7h8i868` or a compatible successor.
|
|
70
|
-
A
|
|
70
|
+
A stable SDK does not qualify experimental learning or enable autonomous execution. ASK/REVIEW/ACT is advice;
|
|
71
71
|
an action still requires independent permission.
|
|
72
72
|
|
|
73
73
|
## Install
|
|
74
74
|
|
|
75
75
|
```bash
|
|
76
|
-
pip install hebbrix==2.6.
|
|
76
|
+
pip install hebbrix==2.6.0
|
|
77
77
|
```
|
|
78
78
|
|
|
79
79
|
Python 3.8+ is supported. `MemoryClient` is asynchronous. `SyncMemoryClient`
|
|
@@ -81,6 +81,15 @@ supports the core collection, memory, search, correction, procedure, and
|
|
|
81
81
|
ProofLoop workflows; advanced temporal, working-memory, consolidation,
|
|
82
82
|
memory-tool, and RL resources are currently async-only.
|
|
83
83
|
|
|
84
|
+
The runtime requires `httpx>=0.25.0,<1`; incompatible 1.x prereleases are excluded.
|
|
85
|
+
For the matching Round 5 backend, `setup_policy` accepts explicit `configuration`
|
|
86
|
+
options for versioned change response and declared optional-field sharing, plus
|
|
87
|
+
`value_objective={"success_value":10,"max_cost":20,"cost_unit":"USD"}`.
|
|
88
|
+
Report both actual success and cost with `record_outcome`. Chat follow-up capture
|
|
89
|
+
requires `features.learning: true` and an exact `outcome_followup.decision_id`;
|
|
90
|
+
captured signals are provisional, not verified execution or autonomy evidence.
|
|
91
|
+
These options require the matching runtime, not only the unchanged database schema.
|
|
92
|
+
|
|
84
93
|
## Quick start
|
|
85
94
|
|
|
86
95
|
October 2 follow-up: `proofloop.setup_policy` atomically creates a new
|
|
@@ -2,15 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
Typed Python client for Hebbrix memory, retrieval, and outcome-learning APIs.
|
|
4
4
|
|
|
5
|
-
This branch is the **2.6.
|
|
5
|
+
This branch is the **2.6.0 stable SDK**. The new policy configuration and
|
|
6
6
|
advice helpers require backend schema `e5f6g7h8i868` or a compatible successor.
|
|
7
|
-
A
|
|
7
|
+
A stable SDK does not qualify experimental learning or enable autonomous execution. ASK/REVIEW/ACT is advice;
|
|
8
8
|
an action still requires independent permission.
|
|
9
9
|
|
|
10
10
|
## Install
|
|
11
11
|
|
|
12
12
|
```bash
|
|
13
|
-
pip install hebbrix==2.6.
|
|
13
|
+
pip install hebbrix==2.6.0
|
|
14
14
|
```
|
|
15
15
|
|
|
16
16
|
Python 3.8+ is supported. `MemoryClient` is asynchronous. `SyncMemoryClient`
|
|
@@ -18,6 +18,15 @@ supports the core collection, memory, search, correction, procedure, and
|
|
|
18
18
|
ProofLoop workflows; advanced temporal, working-memory, consolidation,
|
|
19
19
|
memory-tool, and RL resources are currently async-only.
|
|
20
20
|
|
|
21
|
+
The runtime requires `httpx>=0.25.0,<1`; incompatible 1.x prereleases are excluded.
|
|
22
|
+
For the matching Round 5 backend, `setup_policy` accepts explicit `configuration`
|
|
23
|
+
options for versioned change response and declared optional-field sharing, plus
|
|
24
|
+
`value_objective={"success_value":10,"max_cost":20,"cost_unit":"USD"}`.
|
|
25
|
+
Report both actual success and cost with `record_outcome`. Chat follow-up capture
|
|
26
|
+
requires `features.learning: true` and an exact `outcome_followup.decision_id`;
|
|
27
|
+
captured signals are provisional, not verified execution or autonomy evidence.
|
|
28
|
+
These options require the matching runtime, not only the unchanged database schema.
|
|
29
|
+
|
|
21
30
|
## Quick start
|
|
22
31
|
|
|
23
32
|
October 2 follow-up: `proofloop.setup_policy` atomically creates a new
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Local advisor wire-integrity checks; not authorization or propensity proof."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import math
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _validate_advisor_horizon(remaining_decisions, max_pilot_decisions):
|
|
9
|
+
for field, value, maximum in (("remaining_decisions", remaining_decisions, 10000),
|
|
10
|
+
("max_pilot_decisions", max_pilot_decisions, 8)):
|
|
11
|
+
if value is not None and (type(value) is not int or not 1 <= value <= maximum):
|
|
12
|
+
raise ValueError("{} must be an integer within the API limit".format(field))
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _validate_advisor_scope(policy_key, collection_id, user_id, idempotency_key):
|
|
16
|
+
if (
|
|
17
|
+
type(policy_key) is not str
|
|
18
|
+
or not 1 <= len(policy_key) <= 100
|
|
19
|
+
or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._:-]*", policy_key)
|
|
20
|
+
):
|
|
21
|
+
raise ValueError("policy_key must be an exact valid policy identity")
|
|
22
|
+
for field, value, limit in (
|
|
23
|
+
("collection_id", collection_id, None),
|
|
24
|
+
("user_id", user_id, 255),
|
|
25
|
+
("idempotency_key", idempotency_key, 160),
|
|
26
|
+
):
|
|
27
|
+
if value is not None and (
|
|
28
|
+
type(value) is not str or (limit is not None and len(value) > limit)
|
|
29
|
+
):
|
|
30
|
+
raise ValueError(
|
|
31
|
+
"{} must be an immutable string within the API limit".format(field)
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _json_snapshot(value, field, max_bytes):
|
|
36
|
+
def check(item):
|
|
37
|
+
if type(item) is dict:
|
|
38
|
+
for key, child in item.items():
|
|
39
|
+
if type(key) is not str:
|
|
40
|
+
raise ValueError("{} must contain string JSON keys".format(field))
|
|
41
|
+
check(child)
|
|
42
|
+
elif type(item) is list:
|
|
43
|
+
for child in item:
|
|
44
|
+
check(child)
|
|
45
|
+
elif type(item) not in (str, int, float, bool, type(None)):
|
|
46
|
+
raise ValueError("{} must contain only JSON values".format(field))
|
|
47
|
+
|
|
48
|
+
try:
|
|
49
|
+
check(value)
|
|
50
|
+
encoded = json.dumps(
|
|
51
|
+
value, ensure_ascii=False, separators=(",", ":"), allow_nan=False
|
|
52
|
+
)
|
|
53
|
+
if len(encoded.encode("utf-8")) > max_bytes:
|
|
54
|
+
raise ValueError("{} exceeds the API JSON size limit".format(field))
|
|
55
|
+
return json.loads(encoded)
|
|
56
|
+
except (TypeError, OverflowError, RecursionError) as exc:
|
|
57
|
+
raise ValueError(
|
|
58
|
+
"{} must contain finite acyclic JSON values".format(field)
|
|
59
|
+
) from exc
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _snapshot_advisor_inputs(candidates, context):
|
|
63
|
+
"""Detach inputs before the first evidence request/callback, without coercion."""
|
|
64
|
+
if type(context) is not dict:
|
|
65
|
+
raise ValueError("context must be a JSON object")
|
|
66
|
+
if type(candidates) is not list or not 1 <= len(candidates) <= 50:
|
|
67
|
+
raise ValueError("candidates must contain 1-50 distinct actions")
|
|
68
|
+
context_copy = _json_snapshot(context, "context", 16_384)
|
|
69
|
+
candidates_copy = _json_snapshot(candidates, "candidates", 64_000)
|
|
70
|
+
keys = []
|
|
71
|
+
for candidate in candidates_copy:
|
|
72
|
+
if type(candidate) is not dict or set(candidate) - {
|
|
73
|
+
"action_key",
|
|
74
|
+
"description",
|
|
75
|
+
"features",
|
|
76
|
+
}:
|
|
77
|
+
raise ValueError(
|
|
78
|
+
"candidates must contain only action_key, description and features"
|
|
79
|
+
)
|
|
80
|
+
key = candidate.get("action_key")
|
|
81
|
+
if (
|
|
82
|
+
type(key) is not str
|
|
83
|
+
or not 1 <= len(key) <= 160
|
|
84
|
+
or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._:/-]*", key)
|
|
85
|
+
):
|
|
86
|
+
raise ValueError(
|
|
87
|
+
"candidate action_key must be an exact valid action identity"
|
|
88
|
+
)
|
|
89
|
+
if key in keys:
|
|
90
|
+
raise ValueError("candidate action_key must be unique")
|
|
91
|
+
if candidate.get("description") is not None and (
|
|
92
|
+
type(candidate["description"]) is not str
|
|
93
|
+
or len(candidate["description"]) > 1000
|
|
94
|
+
):
|
|
95
|
+
raise ValueError(
|
|
96
|
+
"candidate description must be a string of at most 1000 characters"
|
|
97
|
+
)
|
|
98
|
+
if "features" in candidate and type(candidate["features"]) is not dict:
|
|
99
|
+
raise ValueError("candidate features must be a JSON object")
|
|
100
|
+
keys.append(key)
|
|
101
|
+
return candidates_copy, context_copy, keys
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _validated_advisor_selection(selection, candidate_keys):
|
|
105
|
+
message = (
|
|
106
|
+
"advisor must return the choice and its actual complete logging distribution"
|
|
107
|
+
)
|
|
108
|
+
if type(selection) is not dict or set(selection) != {
|
|
109
|
+
"chosen_action_key",
|
|
110
|
+
"action_probability",
|
|
111
|
+
"behavior_probabilities",
|
|
112
|
+
}:
|
|
113
|
+
raise ValueError(message)
|
|
114
|
+
chosen = selection["chosen_action_key"]
|
|
115
|
+
if type(chosen) is not str or chosen not in candidate_keys:
|
|
116
|
+
raise ValueError("chosen_action_key must be one of the original candidates")
|
|
117
|
+
probabilities = selection["behavior_probabilities"]
|
|
118
|
+
if type(probabilities) is not dict or set(probabilities) != set(candidate_keys):
|
|
119
|
+
raise ValueError(
|
|
120
|
+
"behavior_probabilities must cover exactly all original candidates"
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
def probability(value):
|
|
124
|
+
if (
|
|
125
|
+
type(value) not in (int, float)
|
|
126
|
+
or not 0 <= value <= 1
|
|
127
|
+
or not math.isfinite(value)
|
|
128
|
+
):
|
|
129
|
+
raise ValueError(
|
|
130
|
+
"logging probabilities must be finite numbers in [0, 1], not confidence strings or booleans"
|
|
131
|
+
)
|
|
132
|
+
return value
|
|
133
|
+
|
|
134
|
+
detached = {key: probability(probabilities[key]) for key in candidate_keys}
|
|
135
|
+
if not math.isclose(sum(detached.values()), 1.0, abs_tol=1e-8, rel_tol=0):
|
|
136
|
+
raise ValueError(
|
|
137
|
+
"behavior_probabilities must sum to 1; the helper never renormalizes"
|
|
138
|
+
)
|
|
139
|
+
selected_probability = probability(selection["action_probability"])
|
|
140
|
+
if selected_probability <= 0 or detached[chosen] <= 0:
|
|
141
|
+
raise ValueError("chosen action must have a positive logging probability")
|
|
142
|
+
if not math.isclose(
|
|
143
|
+
detached[chosen], selected_probability, abs_tol=1e-8, rel_tol=0
|
|
144
|
+
):
|
|
145
|
+
raise ValueError(
|
|
146
|
+
"action_probability must match the chosen action's behavior probability"
|
|
147
|
+
)
|
|
148
|
+
return dict(
|
|
149
|
+
chosen_action_key=chosen,
|
|
150
|
+
action_probability=selected_probability,
|
|
151
|
+
behavior_probabilities=detached,
|
|
152
|
+
)
|
|
@@ -13,6 +13,7 @@ from urllib.parse import quote
|
|
|
13
13
|
|
|
14
14
|
from hebbrix.exceptions import IndexingTimeoutError
|
|
15
15
|
from hebbrix.models import SearchSafetyEnvelope
|
|
16
|
+
from hebbrix._advice import _snapshot_advisor_inputs, _validated_advisor_selection, _validate_advisor_scope, _validate_advisor_horizon
|
|
16
17
|
|
|
17
18
|
if TYPE_CHECKING:
|
|
18
19
|
from hebbrix.client import MemoryClient
|
|
@@ -1380,14 +1381,18 @@ class ProofLoopResource(BaseResource):
|
|
|
1380
1381
|
|
|
1381
1382
|
async def setup_policy(self, policy_key: str, *, context_schema: Dict[str, Any],
|
|
1382
1383
|
actions: Dict[str, Any], collection_id: Optional[str] = None,
|
|
1383
|
-
user_id: Optional[str] = None
|
|
1384
|
+
user_id: Optional[str] = None,
|
|
1385
|
+
configuration: Optional[Dict[str, Any]] = None,
|
|
1386
|
+
value_objective: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
1384
1387
|
"""One atomic setup call. Risk/target/description are supplied by the owner.
|
|
1385
1388
|
Only explicitly low-risk exploration_allowed actions learn by default.
|
|
1386
1389
|
This neither permits execution nor changes an existing policy.
|
|
1387
1390
|
"""
|
|
1388
1391
|
return await self.client.post(f"/v1/learning/policies/{quote(policy_key, safe='')}/setup",
|
|
1389
1392
|
json=dict(collection_id=collection_id, user_id=user_id,
|
|
1390
|
-
context_schema=context_schema,
|
|
1393
|
+
context_schema=context_schema,
|
|
1394
|
+
configuration={**(configuration or {}), "actions": actions},
|
|
1395
|
+
**({"value_objective": value_objective} if value_objective is not None else {})))
|
|
1391
1396
|
|
|
1392
1397
|
async def learning_report(self, policy_key: str, *, days: int = 7,
|
|
1393
1398
|
collection_id: Optional[str] = None,
|
|
@@ -1401,18 +1406,27 @@ class ProofLoopResource(BaseResource):
|
|
|
1401
1406
|
context: Dict[str, Any], advisor,
|
|
1402
1407
|
collection_id: Optional[str] = None,
|
|
1403
1408
|
user_id: Optional[str] = None,
|
|
1404
|
-
idempotency_key: Optional[str] = None
|
|
1409
|
+
idempotency_key: Optional[str] = None,
|
|
1410
|
+
remaining_decisions: Optional[int] = None,
|
|
1411
|
+
max_pilot_decisions: Optional[int] = None) -> Dict[str, Any]:
|
|
1405
1412
|
"""Read evidence, call your advisor once, then log its explicit choice.
|
|
1406
1413
|
advisor must return chosen_action_key, action_probability and the complete
|
|
1407
1414
|
behavior_probabilities. No model confidence is invented as a propensity.
|
|
1408
|
-
|
|
1409
|
-
|
|
1415
|
+
Inputs are detached before reading evidence; callback mutations cannot
|
|
1416
|
+
change the logged scope/candidates/context. Malformed distributions fail
|
|
1417
|
+
before logging, without retries or normalization. Caller probabilities
|
|
1418
|
+
remain caller-reported, not authenticated randomization. No tool execution
|
|
1419
|
+
or outcome is recorded by this helper.
|
|
1420
|
+
"""
|
|
1421
|
+
_validate_advisor_scope(policy_key, collection_id, user_id, idempotency_key)
|
|
1422
|
+
_validate_advisor_horizon(remaining_decisions, max_pilot_decisions)
|
|
1423
|
+
candidates, context, keys = _snapshot_advisor_inputs(candidates, context)
|
|
1424
|
+
if not callable(advisor):
|
|
1425
|
+
raise ValueError("advisor must be callable")
|
|
1410
1426
|
card = await self.policy_advice(policy_key, context=context,
|
|
1411
|
-
collection_id=collection_id, user_id=user_id
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
"chosen_action_key", "action_probability", "behavior_probabilities"}:
|
|
1415
|
-
raise ValueError("advisor must return the choice and its actual complete logging distribution")
|
|
1427
|
+
collection_id=collection_id, user_id=user_id,
|
|
1428
|
+
remaining_decisions=remaining_decisions, max_pilot_decisions=max_pilot_decisions)
|
|
1429
|
+
selection = _validated_advisor_selection(await advisor(card), keys)
|
|
1416
1430
|
return await self.decide(policy_key=policy_key, candidates=candidates, context=context,
|
|
1417
1431
|
collection_id=collection_id, user_id=user_id, idempotency_key=idempotency_key,
|
|
1418
1432
|
mode="observe", **selection)
|
|
@@ -1432,11 +1446,14 @@ class ProofLoopResource(BaseResource):
|
|
|
1432
1446
|
params={k: v for k, v in dict(collection_id=collection_id, user_id=user_id).items() if v is not None})
|
|
1433
1447
|
|
|
1434
1448
|
async def policy_advice(self, policy_key: str, *, context: Optional[Dict[str, Any]] = None,
|
|
1435
|
-
collection_id: Optional[str] = None, user_id: Optional[str] = None
|
|
1449
|
+
collection_id: Optional[str] = None, user_id: Optional[str] = None,
|
|
1450
|
+
remaining_decisions: Optional[int] = None,
|
|
1451
|
+
max_pilot_decisions: Optional[int] = None) -> Dict[str, Any]:
|
|
1436
1452
|
"""Read caller-reported evidence and a candidate, never an execution permit."""
|
|
1437
1453
|
return await self.client.get(f"/v1/learning/policies/{quote(policy_key, safe='')}/advice",
|
|
1438
1454
|
params={k: v for k, v in dict(context=json.dumps(context or {}),
|
|
1439
|
-
collection_id=collection_id, user_id=user_id
|
|
1455
|
+
collection_id=collection_id, user_id=user_id,
|
|
1456
|
+
remaining_decisions=remaining_decisions, max_pilot_decisions=max_pilot_decisions).items() if v is not None})
|
|
1440
1457
|
|
|
1441
1458
|
async def action_advice(self, query: str, *, policy_key: str, action_key: str,
|
|
1442
1459
|
context: Optional[Dict[str, Any]] = None, collection_id: Optional[str] = None,
|
|
@@ -18,6 +18,7 @@ from hebbrix.exceptions import (
|
|
|
18
18
|
ValidationError,
|
|
19
19
|
)
|
|
20
20
|
from hebbrix.resources import _canonical_search_envelope, _memory_create_payload
|
|
21
|
+
from hebbrix._advice import _snapshot_advisor_inputs, _validated_advisor_selection, _validate_advisor_scope, _validate_advisor_horizon
|
|
21
22
|
|
|
22
23
|
|
|
23
24
|
class SyncCollectionsResource:
|
|
@@ -858,14 +859,18 @@ class SyncProofLoopResource:
|
|
|
858
859
|
|
|
859
860
|
def setup_policy(self, policy_key: str, *, context_schema: Dict[str, Any],
|
|
860
861
|
actions: Dict[str, Any], collection_id: Optional[str] = None,
|
|
861
|
-
user_id: Optional[str] = None
|
|
862
|
+
user_id: Optional[str] = None,
|
|
863
|
+
configuration: Optional[Dict[str, Any]] = None,
|
|
864
|
+
value_objective: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
862
865
|
"""One atomic setup call. Risk/target/description are supplied by the owner.
|
|
863
866
|
Only explicitly low-risk exploration_allowed actions learn by default.
|
|
864
867
|
This neither permits execution nor changes an existing policy.
|
|
865
868
|
"""
|
|
866
869
|
return self.client.post(f"/v1/learning/policies/{quote(policy_key, safe='')}/setup",
|
|
867
870
|
json=dict(collection_id=collection_id, user_id=user_id,
|
|
868
|
-
context_schema=context_schema,
|
|
871
|
+
context_schema=context_schema,
|
|
872
|
+
configuration={**(configuration or {}), "actions": actions},
|
|
873
|
+
**({"value_objective": value_objective} if value_objective is not None else {})))
|
|
869
874
|
|
|
870
875
|
def learning_report(self, policy_key: str, *, days: int = 7,
|
|
871
876
|
collection_id: Optional[str] = None,
|
|
@@ -879,18 +884,27 @@ class SyncProofLoopResource:
|
|
|
879
884
|
context: Dict[str, Any], advisor,
|
|
880
885
|
collection_id: Optional[str] = None,
|
|
881
886
|
user_id: Optional[str] = None,
|
|
882
|
-
idempotency_key: Optional[str] = None
|
|
887
|
+
idempotency_key: Optional[str] = None,
|
|
888
|
+
remaining_decisions: Optional[int] = None,
|
|
889
|
+
max_pilot_decisions: Optional[int] = None) -> Dict[str, Any]:
|
|
883
890
|
"""Read evidence, call your advisor once, then log its explicit choice.
|
|
884
891
|
advisor must return chosen_action_key, action_probability and the complete
|
|
885
892
|
behavior_probabilities. No model confidence is invented as a propensity.
|
|
886
|
-
|
|
893
|
+
Inputs are detached before reading evidence; callback mutations cannot
|
|
894
|
+
change the logged scope/candidates/context. Malformed distributions fail
|
|
895
|
+
before logging, without retries or normalization. Caller probabilities
|
|
896
|
+
remain caller-reported, not authenticated randomization. No tool execution
|
|
897
|
+
or outcome is recorded by this helper.
|
|
887
898
|
"""
|
|
899
|
+
_validate_advisor_scope(policy_key, collection_id, user_id, idempotency_key)
|
|
900
|
+
_validate_advisor_horizon(remaining_decisions, max_pilot_decisions)
|
|
901
|
+
candidates, context, keys = _snapshot_advisor_inputs(candidates, context)
|
|
902
|
+
if not callable(advisor):
|
|
903
|
+
raise ValueError("advisor must be callable")
|
|
888
904
|
card = self.policy_advice(policy_key, context=context,
|
|
889
|
-
collection_id=collection_id, user_id=user_id
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
"chosen_action_key", "action_probability", "behavior_probabilities"}:
|
|
893
|
-
raise ValueError("advisor must return the choice and its actual complete logging distribution")
|
|
905
|
+
collection_id=collection_id, user_id=user_id,
|
|
906
|
+
remaining_decisions=remaining_decisions, max_pilot_decisions=max_pilot_decisions)
|
|
907
|
+
selection = _validated_advisor_selection(advisor(card), keys)
|
|
894
908
|
return self.decide(policy_key=policy_key, candidates=candidates, context=context,
|
|
895
909
|
collection_id=collection_id, user_id=user_id, idempotency_key=idempotency_key,
|
|
896
910
|
mode="observe", **selection)
|
|
@@ -910,11 +924,14 @@ class SyncProofLoopResource:
|
|
|
910
924
|
params={k: v for k, v in dict(collection_id=collection_id, user_id=user_id).items() if v is not None})
|
|
911
925
|
|
|
912
926
|
def policy_advice(self, policy_key: str, *, context: Optional[Dict[str, Any]] = None,
|
|
913
|
-
|
|
927
|
+
collection_id: Optional[str] = None, user_id: Optional[str] = None,
|
|
928
|
+
remaining_decisions: Optional[int] = None,
|
|
929
|
+
max_pilot_decisions: Optional[int] = None) -> Dict[str, Any]:
|
|
914
930
|
"""Read caller-reported evidence and a candidate, never an execution permit."""
|
|
915
931
|
return self.client.get(f"/v1/learning/policies/{quote(policy_key, safe='')}/advice",
|
|
916
932
|
params={k: v for k, v in dict(context=json.dumps(context or {}),
|
|
917
|
-
collection_id=collection_id, user_id=user_id
|
|
933
|
+
collection_id=collection_id, user_id=user_id,
|
|
934
|
+
remaining_decisions=remaining_decisions, max_pilot_decisions=max_pilot_decisions).items() if v is not None})
|
|
918
935
|
|
|
919
936
|
def action_advice(self, query: str, *, policy_key: str, action_key: str,
|
|
920
937
|
context: Optional[Dict[str, Any]] = None, collection_id: Optional[str] = None,
|
|
@@ -1051,7 +1068,7 @@ class SyncMemoryClient:
|
|
|
1051
1068
|
self.source = source or os.getenv("HEBBRIX_SOURCE")
|
|
1052
1069
|
headers = {
|
|
1053
1070
|
"Content-Type": "application/json",
|
|
1054
|
-
"User-Agent": "hebbrix-python/2.6.
|
|
1071
|
+
"User-Agent": "hebbrix-python/2.6.1",
|
|
1055
1072
|
}
|
|
1056
1073
|
if api_key:
|
|
1057
1074
|
headers["Authorization"] = f"Bearer {api_key}"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: hebbrix
|
|
3
|
-
Version: 2.6.
|
|
3
|
+
Version: 2.6.1
|
|
4
4
|
Summary: Typed Python client for Hebbrix memory, retrieval, and outcome-learning APIs
|
|
5
5
|
Author-email: Hebbrix Team <support@hebbrix.com>
|
|
6
6
|
Maintainer-email: Hebbrix Team <support@hebbrix.com>
|
|
@@ -33,7 +33,7 @@ Project-URL: Issues, https://github.com/Hebbrix/hebbrix-python/issues
|
|
|
33
33
|
Project-URL: Support, https://www.hebbrix.com/contact
|
|
34
34
|
Project-URL: API Reference, https://api.hebbrix.com/docs
|
|
35
35
|
Keywords: ai,memory,agents,llm,chatbot,assistant,ml,reinforcement-learning,knowledge-graph,vector-search,rag,temporal,procedural-memory,working-memory
|
|
36
|
-
Classifier: Development Status ::
|
|
36
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
37
37
|
Classifier: Intended Audience :: Developers
|
|
38
38
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
39
39
|
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
@@ -49,7 +49,7 @@ Classifier: Framework :: AsyncIO
|
|
|
49
49
|
Requires-Python: >=3.8
|
|
50
50
|
Description-Content-Type: text/markdown
|
|
51
51
|
License-File: LICENSE
|
|
52
|
-
Requires-Dist: httpx
|
|
52
|
+
Requires-Dist: httpx<1,>=0.25.0
|
|
53
53
|
Requires-Dist: requests>=2.31.0
|
|
54
54
|
Provides-Extra: dev
|
|
55
55
|
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
@@ -65,15 +65,15 @@ Dynamic: license-file
|
|
|
65
65
|
|
|
66
66
|
Typed Python client for Hebbrix memory, retrieval, and outcome-learning APIs.
|
|
67
67
|
|
|
68
|
-
This branch is the **2.6.
|
|
68
|
+
This branch is the **2.6.0 stable SDK**. The new policy configuration and
|
|
69
69
|
advice helpers require backend schema `e5f6g7h8i868` or a compatible successor.
|
|
70
|
-
A
|
|
70
|
+
A stable SDK does not qualify experimental learning or enable autonomous execution. ASK/REVIEW/ACT is advice;
|
|
71
71
|
an action still requires independent permission.
|
|
72
72
|
|
|
73
73
|
## Install
|
|
74
74
|
|
|
75
75
|
```bash
|
|
76
|
-
pip install hebbrix==2.6.
|
|
76
|
+
pip install hebbrix==2.6.0
|
|
77
77
|
```
|
|
78
78
|
|
|
79
79
|
Python 3.8+ is supported. `MemoryClient` is asynchronous. `SyncMemoryClient`
|
|
@@ -81,6 +81,15 @@ supports the core collection, memory, search, correction, procedure, and
|
|
|
81
81
|
ProofLoop workflows; advanced temporal, working-memory, consolidation,
|
|
82
82
|
memory-tool, and RL resources are currently async-only.
|
|
83
83
|
|
|
84
|
+
The runtime requires `httpx>=0.25.0,<1`; incompatible 1.x prereleases are excluded.
|
|
85
|
+
For the matching Round 5 backend, `setup_policy` accepts explicit `configuration`
|
|
86
|
+
options for versioned change response and declared optional-field sharing, plus
|
|
87
|
+
`value_objective={"success_value":10,"max_cost":20,"cost_unit":"USD"}`.
|
|
88
|
+
Report both actual success and cost with `record_outcome`. Chat follow-up capture
|
|
89
|
+
requires `features.learning: true` and an exact `outcome_followup.decision_id`;
|
|
90
|
+
captured signals are provisional, not verified execution or autonomy evidence.
|
|
91
|
+
These options require the matching runtime, not only the unchanged database schema.
|
|
92
|
+
|
|
84
93
|
## Quick start
|
|
85
94
|
|
|
86
95
|
October 2 follow-up: `proofloop.setup_policy` atomically creates a new
|
|
@@ -7,6 +7,7 @@ contracts/openapi-routes.json
|
|
|
7
7
|
docs/native-experience-operations.md
|
|
8
8
|
examples/native_reflection_worker.py
|
|
9
9
|
hebbrix/__init__.py
|
|
10
|
+
hebbrix/_advice.py
|
|
10
11
|
hebbrix/chat.py
|
|
11
12
|
hebbrix/client.py
|
|
12
13
|
hebbrix/exceptions.py
|
|
@@ -25,6 +26,7 @@ hebbrix.egg-info/entry_points.txt
|
|
|
25
26
|
hebbrix.egg-info/requires.txt
|
|
26
27
|
hebbrix.egg-info/top_level.txt
|
|
27
28
|
tests/test_advanced_resources.py
|
|
29
|
+
tests/test_advisor_integrity.py
|
|
28
30
|
tests/test_evidence_loop_resource.py
|
|
29
31
|
tests/test_evidence_safety_boundary.py
|
|
30
32
|
tests/test_execution_permission.py
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "hebbrix"
|
|
7
|
-
version = "2.6.
|
|
7
|
+
version = "2.6.1"
|
|
8
8
|
description = "Typed Python client for Hebbrix memory, retrieval, and outcome-learning APIs"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.8"
|
|
@@ -33,7 +33,7 @@ keywords = [
|
|
|
33
33
|
"working-memory"
|
|
34
34
|
]
|
|
35
35
|
classifiers = [
|
|
36
|
-
"Development Status ::
|
|
36
|
+
"Development Status :: 5 - Production/Stable",
|
|
37
37
|
"Intended Audience :: Developers",
|
|
38
38
|
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
39
39
|
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
@@ -48,7 +48,7 @@ classifiers = [
|
|
|
48
48
|
"Framework :: AsyncIO",
|
|
49
49
|
]
|
|
50
50
|
dependencies = [
|
|
51
|
-
"httpx>=0.25.0",
|
|
51
|
+
"httpx>=0.25.0,<1",
|
|
52
52
|
"requests>=2.31.0",
|
|
53
53
|
]
|
|
54
54
|
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"""SDK contract controls, not model reliability or learning-performance tests."""
|
|
2
|
+
|
|
3
|
+
import copy
|
|
4
|
+
import json
|
|
5
|
+
from unittest.mock import AsyncMock, Mock
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from hebbrix.resources import ProofLoopResource
|
|
10
|
+
from hebbrix.sync_client import SyncProofLoopResource
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def inputs():
|
|
14
|
+
return dict(
|
|
15
|
+
policy_key="integrity",
|
|
16
|
+
candidates=[
|
|
17
|
+
{"action_key": "a", "features": {"nested": ["original"]}},
|
|
18
|
+
{"action_key": "b"},
|
|
19
|
+
],
|
|
20
|
+
context={"scope": {"environment": "sandbox"}},
|
|
21
|
+
collection_id="c",
|
|
22
|
+
user_id="u",
|
|
23
|
+
idempotency_key="once",
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def choice():
|
|
28
|
+
return dict(
|
|
29
|
+
chosen_action_key="b",
|
|
30
|
+
action_probability=1.0,
|
|
31
|
+
behavior_probabilities={"a": 0.0, "b": 1.0},
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
async def invoke(kind, client, params, advisor):
|
|
36
|
+
if kind == "sync":
|
|
37
|
+
return SyncProofLoopResource(client).decide_with_advice(
|
|
38
|
+
**params, advisor=advisor
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
async def callback(card):
|
|
42
|
+
return advisor(card)
|
|
43
|
+
|
|
44
|
+
return await ProofLoopResource(client).decide_with_advice(
|
|
45
|
+
**params, advisor=callback
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def transport(kind):
|
|
50
|
+
client = Mock()
|
|
51
|
+
card = {"authorization_granted": False, "evidence_card": "untrusted"}
|
|
52
|
+
client.get = (
|
|
53
|
+
Mock(return_value=card) if kind == "sync" else AsyncMock(return_value=card)
|
|
54
|
+
)
|
|
55
|
+
client.post = (
|
|
56
|
+
Mock(return_value={"decision_id": "d"})
|
|
57
|
+
if kind == "sync"
|
|
58
|
+
else AsyncMock(return_value={"decision_id": "d"})
|
|
59
|
+
)
|
|
60
|
+
return client
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@pytest.mark.asyncio
|
|
64
|
+
@pytest.mark.parametrize("kind", ["sync", "async"])
|
|
65
|
+
async def test_pilot_horizon_is_advice_only_not_a_decision_input(kind):
|
|
66
|
+
client = transport(kind)
|
|
67
|
+
await invoke(kind, client, {**inputs(), "remaining_decisions": 80, "max_pilot_decisions": 2}, Mock(return_value=choice()))
|
|
68
|
+
query = client.get.call_args.kwargs["params"]
|
|
69
|
+
assert query["remaining_decisions"] == 80 and query["max_pilot_decisions"] == 2
|
|
70
|
+
body = client.post.call_args.kwargs["json"]
|
|
71
|
+
assert "remaining_decisions" not in body and "max_pilot_decisions" not in body
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@pytest.mark.asyncio
|
|
75
|
+
@pytest.mark.parametrize("kind", ["sync", "async"])
|
|
76
|
+
@pytest.mark.parametrize("horizon", [True, 0, 10001, 1.5])
|
|
77
|
+
async def test_invalid_horizon_fails_before_advisor_or_reads(kind, horizon):
|
|
78
|
+
client, advisor = transport(kind), Mock(return_value=choice())
|
|
79
|
+
with pytest.raises(ValueError):
|
|
80
|
+
await invoke(kind, client, {**inputs(), "remaining_decisions": horizon}, advisor)
|
|
81
|
+
client.get.assert_not_called()
|
|
82
|
+
advisor.assert_not_called()
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@pytest.mark.asyncio
|
|
86
|
+
@pytest.mark.parametrize("kind", ["sync", "async"])
|
|
87
|
+
@pytest.mark.parametrize("stage", ["evidence_read", "advisor"])
|
|
88
|
+
async def test_original_nested_inputs_survive_mutation(kind, stage):
|
|
89
|
+
client, params, selected = transport(kind), inputs(), choice()
|
|
90
|
+
original = copy.deepcopy(params)
|
|
91
|
+
|
|
92
|
+
def mutate():
|
|
93
|
+
params["context"]["scope"]["environment"] = "production"
|
|
94
|
+
params["candidates"][0]["features"]["nested"].append("changed")
|
|
95
|
+
params["candidates"].append({"action_key": "foreign"})
|
|
96
|
+
|
|
97
|
+
if stage == "evidence_read":
|
|
98
|
+
|
|
99
|
+
def read(*args, **kwargs):
|
|
100
|
+
mutate()
|
|
101
|
+
return {"authorization_granted": False}
|
|
102
|
+
|
|
103
|
+
client.get.side_effect = read
|
|
104
|
+
|
|
105
|
+
def callback(card):
|
|
106
|
+
if stage == "advisor":
|
|
107
|
+
mutate()
|
|
108
|
+
return selected
|
|
109
|
+
|
|
110
|
+
advisor = Mock(side_effect=callback)
|
|
111
|
+
await invoke(kind, client, params, advisor)
|
|
112
|
+
assert (
|
|
113
|
+
json.loads(client.get.call_args.kwargs["params"]["context"])
|
|
114
|
+
== original["context"]
|
|
115
|
+
)
|
|
116
|
+
body = client.post.call_args.kwargs["json"]
|
|
117
|
+
assert body["context"] == original["context"]
|
|
118
|
+
assert body["candidates"] == original["candidates"]
|
|
119
|
+
assert body["user_id"] == "u" and body["collection_id"] == "c"
|
|
120
|
+
assert body["mode"] == "observe" and body["idempotency_key"] == "once"
|
|
121
|
+
advisor.assert_called_once()
|
|
122
|
+
assert client.post.call_count == 1
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@pytest.mark.asyncio
|
|
126
|
+
@pytest.mark.parametrize("kind", ["sync", "async"])
|
|
127
|
+
async def test_returned_distribution_is_not_a_mutable_transport_alias(kind):
|
|
128
|
+
client, selected = transport(kind), choice()
|
|
129
|
+
await invoke(kind, client, inputs(), Mock(return_value=selected))
|
|
130
|
+
selected["behavior_probabilities"]["b"] = 0
|
|
131
|
+
assert client.post.call_args.kwargs["json"]["behavior_probabilities"] == {
|
|
132
|
+
"a": 0.0,
|
|
133
|
+
"b": 1.0,
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
BAD_CHOICES = [
|
|
138
|
+
None,
|
|
139
|
+
[],
|
|
140
|
+
{**choice(), "user_id": "foreign"},
|
|
141
|
+
{**choice(), "chosen_action_key": "foreign"},
|
|
142
|
+
{**choice(), "chosen_action_key": None},
|
|
143
|
+
{**choice(), "behavior_probabilities": None},
|
|
144
|
+
{**choice(), "behavior_probabilities": {"b": 1}},
|
|
145
|
+
{**choice(), "behavior_probabilities": {"a": 0, "b": 1, "c": 0}},
|
|
146
|
+
{**choice(), "behavior_probabilities": {"a": 0, "b": True}},
|
|
147
|
+
{**choice(), "behavior_probabilities": {"a": 0, "b": "1"}},
|
|
148
|
+
{**choice(), "behavior_probabilities": {"a": 0, "b": float("nan")}},
|
|
149
|
+
{**choice(), "behavior_probabilities": {"a": 0, "b": float("inf")}},
|
|
150
|
+
{**choice(), "behavior_probabilities": {"a": -0.1, "b": 1.1}},
|
|
151
|
+
{**choice(), "behavior_probabilities": {"a": 0.1, "b": 1}},
|
|
152
|
+
{**choice(), "action_probability": True},
|
|
153
|
+
{**choice(), "action_probability": "1"},
|
|
154
|
+
{**choice(), "action_probability": None},
|
|
155
|
+
{**choice(), "action_probability": float("nan")},
|
|
156
|
+
{**choice(), "action_probability": 0},
|
|
157
|
+
{**choice(), "action_probability": 1.1},
|
|
158
|
+
{**choice(), "action_probability": 0.5},
|
|
159
|
+
{
|
|
160
|
+
"chosen_action_key": "b",
|
|
161
|
+
"action_probability": 0,
|
|
162
|
+
"behavior_probabilities": {"a": 1, "b": 0},
|
|
163
|
+
},
|
|
164
|
+
]
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@pytest.mark.asyncio
|
|
168
|
+
@pytest.mark.parametrize("kind", ["sync", "async"])
|
|
169
|
+
@pytest.mark.parametrize("selected", BAD_CHOICES, ids=range(len(BAD_CHOICES)))
|
|
170
|
+
async def test_invalid_advisor_distribution_never_writes(kind, selected):
|
|
171
|
+
client, advisor = transport(kind), Mock(return_value=selected)
|
|
172
|
+
with pytest.raises(ValueError):
|
|
173
|
+
await invoke(kind, client, inputs(), advisor)
|
|
174
|
+
assert client.get.call_count == 1 and advisor.call_count == 1
|
|
175
|
+
client.post.assert_not_called()
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
BAD_INPUTS = [
|
|
179
|
+
{"policy_key": " invalid"},
|
|
180
|
+
{"policy_key": "path/policy"},
|
|
181
|
+
{"policy_key": {}},
|
|
182
|
+
{"collection_id": {}},
|
|
183
|
+
{"user_id": {}},
|
|
184
|
+
{"idempotency_key": []},
|
|
185
|
+
{"user_id": "x" * 256},
|
|
186
|
+
{"idempotency_key": "x" * 161},
|
|
187
|
+
{"candidates": []},
|
|
188
|
+
{"candidates": [{"action_key": "a"}] * 2},
|
|
189
|
+
{"candidates": [{"action_key": " a"}]},
|
|
190
|
+
{"candidates": [{"action_key": True}]},
|
|
191
|
+
{"candidates": [{"action_key": "a", "unexpected": "field"}]},
|
|
192
|
+
{"candidates": [{"action_key": "a", "features": []}]},
|
|
193
|
+
{"candidates": [{"action_key": "a", "description": 12}]},
|
|
194
|
+
{"candidates": [{"action_key": str(i)} for i in range(51)]},
|
|
195
|
+
{"context": []},
|
|
196
|
+
{"context": {1: "not a JSON object key"}},
|
|
197
|
+
{"context": {"x": float("nan")}},
|
|
198
|
+
{"context": {"x": object()}},
|
|
199
|
+
{"context": {"x": (1, 2)}},
|
|
200
|
+
{"context": {"x": "x" * 17000}},
|
|
201
|
+
]
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@pytest.mark.asyncio
|
|
205
|
+
@pytest.mark.parametrize("kind", ["sync", "async"])
|
|
206
|
+
@pytest.mark.parametrize("bad", BAD_INPUTS, ids=range(len(BAD_INPUTS)))
|
|
207
|
+
async def test_invalid_inputs_fail_before_read_or_advisor(kind, bad):
|
|
208
|
+
client, advisor = transport(kind), Mock(return_value=choice())
|
|
209
|
+
with pytest.raises(ValueError):
|
|
210
|
+
await invoke(kind, client, {**inputs(), **bad}, advisor)
|
|
211
|
+
client.get.assert_not_called()
|
|
212
|
+
advisor.assert_not_called()
|
|
213
|
+
client.post.assert_not_called()
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
@pytest.mark.asyncio
|
|
217
|
+
@pytest.mark.parametrize("kind", ["sync", "async"])
|
|
218
|
+
@pytest.mark.parametrize(
|
|
219
|
+
"probabilities",
|
|
220
|
+
[{"a": 0.3, "b": 0.7}, {"a": 0.0, "b": 1.0}, {"a": 0.3, "b": 0.700000005}],
|
|
221
|
+
)
|
|
222
|
+
async def test_valid_distribution_is_preserved_not_renormalized(kind, probabilities):
|
|
223
|
+
client = transport(kind)
|
|
224
|
+
selected = dict(
|
|
225
|
+
chosen_action_key="b",
|
|
226
|
+
action_probability=probabilities["b"],
|
|
227
|
+
behavior_probabilities=probabilities,
|
|
228
|
+
)
|
|
229
|
+
await invoke(kind, client, inputs(), Mock(return_value=selected))
|
|
230
|
+
assert (
|
|
231
|
+
client.post.call_args.kwargs["json"]["behavior_probabilities"] == probabilities
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
@pytest.mark.asyncio
|
|
236
|
+
@pytest.mark.parametrize("kind", ["sync", "async"])
|
|
237
|
+
async def test_single_action_requires_explicit_complete_distribution(kind):
|
|
238
|
+
client = transport(kind)
|
|
239
|
+
params = {**inputs(), "candidates": [{"action_key": "a"}]}
|
|
240
|
+
await invoke(
|
|
241
|
+
kind,
|
|
242
|
+
client,
|
|
243
|
+
params,
|
|
244
|
+
Mock(
|
|
245
|
+
return_value=dict(
|
|
246
|
+
chosen_action_key="a",
|
|
247
|
+
action_probability=1,
|
|
248
|
+
behavior_probabilities={"a": 1},
|
|
249
|
+
)
|
|
250
|
+
),
|
|
251
|
+
)
|
|
252
|
+
assert client.post.call_count == 1
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
@pytest.mark.asyncio
|
|
256
|
+
@pytest.mark.parametrize("kind", ["sync", "async"])
|
|
257
|
+
@pytest.mark.parametrize("stage", ["read", "advisor", "write"])
|
|
258
|
+
async def test_failures_propagate_without_retry(kind, stage):
|
|
259
|
+
client, advisor = transport(kind), Mock(return_value=choice())
|
|
260
|
+
target = {"read": client.get, "advisor": advisor, "write": client.post}[stage]
|
|
261
|
+
target.side_effect = RuntimeError("bounded failure")
|
|
262
|
+
with pytest.raises(RuntimeError, match="bounded failure"):
|
|
263
|
+
await invoke(kind, client, inputs(), advisor)
|
|
264
|
+
assert client.get.call_count == 1
|
|
265
|
+
assert advisor.call_count == (0 if stage == "read" else 1)
|
|
266
|
+
assert client.post.call_count == (1 if stage == "write" else 0)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
@pytest.mark.asyncio
|
|
270
|
+
@pytest.mark.parametrize("kind", ["sync", "async"])
|
|
271
|
+
async def test_cyclic_json_is_refused_before_read(kind):
|
|
272
|
+
client, advisor, params = transport(kind), Mock(return_value=choice()), inputs()
|
|
273
|
+
params["context"]["self"] = params["context"]
|
|
274
|
+
with pytest.raises(ValueError):
|
|
275
|
+
await invoke(kind, client, params, advisor)
|
|
276
|
+
client.get.assert_not_called()
|
|
@@ -13,6 +13,7 @@ from hebbrix.sync_client import SyncProofLoopResource
|
|
|
13
13
|
("configure_policy", ("a/b",), {"configuration": {"actions": {}}}, "request", "/v1/learning/policies/a%2Fb/configuration"),
|
|
14
14
|
("policy_configuration", ("a/b",), {}, "get", "/v1/learning/policies/a%2Fb/configuration"),
|
|
15
15
|
("policy_advice", ("a/b",), {"context": {"x": "a b"}}, "get", "/v1/learning/policies/a%2Fb/advice"),
|
|
16
|
+
("policy_advice", ("a/b",), {"context": {}, "remaining_decisions": 100, "max_pilot_decisions": 2}, "get", "/v1/learning/policies/a%2Fb/advice"),
|
|
16
17
|
("action_advice", ("Restart sandbox",), {"policy_key": "p", "action_key": "a", "user_id": "scoped"}, "get", "/v1/confidence"),
|
|
17
18
|
])
|
|
18
19
|
async def test_sync_async_policy_wire_parity(method, args, kwargs, verb, path):
|
|
@@ -29,6 +30,25 @@ async def test_sync_async_policy_wire_parity(method, args, kwargs, verb, path):
|
|
|
29
30
|
assert call.args[0] == "PUT" and call.kwargs["json"]["expected_revision"] == 0
|
|
30
31
|
if method == "policy_advice":
|
|
31
32
|
assert json.loads(call.kwargs["params"]["context"]) == kwargs["context"]
|
|
33
|
+
for key in ("remaining_decisions", "max_pilot_decisions"):
|
|
34
|
+
if key in kwargs:
|
|
35
|
+
assert call.kwargs["params"][key] == kwargs[key]
|
|
32
36
|
if method == "action_advice":
|
|
33
37
|
assert call.kwargs["params"]["end_user_id"] == "scoped"
|
|
34
38
|
assert "user_id" not in call.kwargs["params"]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@pytest.mark.asyncio
|
|
42
|
+
async def test_value_and_feature_setup_sync_async_parity():
|
|
43
|
+
sync, asynchronous = Mock(), Mock()
|
|
44
|
+
sync.post.return_value = {"execution_permitted": False}
|
|
45
|
+
asynchronous.post = AsyncMock(return_value={"execution_permitted": False})
|
|
46
|
+
values = dict(context_schema={"version": "v1", "fields": {}},
|
|
47
|
+
actions={"a": {"risk_tier": "high"}},
|
|
48
|
+
configuration={"schema_version": "outcome-policy-v3", "contextual_model": "linear_optional"},
|
|
49
|
+
value_objective={"success_value": 10, "max_cost": 20, "cost_unit": "USD"})
|
|
50
|
+
assert SyncProofLoopResource(sync).setup_policy("p", **values) == await ProofLoopResource(asynchronous).setup_policy("p", **values)
|
|
51
|
+
assert sync.post.call_args == asynchronous.post.call_args
|
|
52
|
+
body = sync.post.call_args.kwargs["json"]
|
|
53
|
+
assert body["configuration"]["actions"] == values["actions"]
|
|
54
|
+
assert body["value_objective"] == values["value_objective"]
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Prevent package identity from drifting from either HTTP transport."""
|
|
2
|
+
import inspect
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
import hebbrix
|
|
6
|
+
from hebbrix import client, sync_client
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_both_transport_versions_match_package():
|
|
10
|
+
for module in (client, sync_client):
|
|
11
|
+
versions = re.findall(r'hebbrix-python/([^\s\"\x27]+)', inspect.getsource(module))
|
|
12
|
+
assert versions == [hebbrix.__version__]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_runtime_httpx_is_bounded_below_breaking_major():
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from packaging.requirements import Requirement
|
|
18
|
+
text = (Path(__file__).resolve().parents[1] / "pyproject.toml").read_text()
|
|
19
|
+
requirement = Requirement(re.search(r'"(httpx[^"\n]+)"', text).group(1))
|
|
20
|
+
assert requirement.specifier.contains("0.28.1")
|
|
21
|
+
assert not requirement.specifier.contains("1.0.dev6", prereleases=True)
|
|
22
|
+
assert not requirement.specifier.contains("1.0.0")
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
"""Prevent package identity from drifting from either HTTP transport."""
|
|
2
|
-
import inspect
|
|
3
|
-
import re
|
|
4
|
-
|
|
5
|
-
import hebbrix
|
|
6
|
-
from hebbrix import client, sync_client
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
def test_both_transport_versions_match_package():
|
|
10
|
-
for module in (client, sync_client):
|
|
11
|
-
versions = re.findall(r'hebbrix-python/([^\s\"\x27]+)', inspect.getsource(module))
|
|
12
|
-
assert versions == [hebbrix.__version__]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|