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.
Files changed (47) hide show
  1. {hebbrix-2.6.0rc4 → hebbrix-2.6.1}/CHANGELOG.md +20 -0
  2. {hebbrix-2.6.0rc4/hebbrix.egg-info → hebbrix-2.6.1}/PKG-INFO +15 -6
  3. {hebbrix-2.6.0rc4 → hebbrix-2.6.1}/README.md +12 -3
  4. {hebbrix-2.6.0rc4 → hebbrix-2.6.1}/hebbrix/__init__.py +1 -1
  5. hebbrix-2.6.1/hebbrix/_advice.py +152 -0
  6. {hebbrix-2.6.0rc4 → hebbrix-2.6.1}/hebbrix/client.py +1 -1
  7. {hebbrix-2.6.0rc4 → hebbrix-2.6.1}/hebbrix/resources.py +29 -12
  8. {hebbrix-2.6.0rc4 → hebbrix-2.6.1}/hebbrix/sync_client.py +29 -12
  9. {hebbrix-2.6.0rc4 → hebbrix-2.6.1/hebbrix.egg-info}/PKG-INFO +15 -6
  10. {hebbrix-2.6.0rc4 → hebbrix-2.6.1}/hebbrix.egg-info/SOURCES.txt +2 -0
  11. {hebbrix-2.6.0rc4 → hebbrix-2.6.1}/hebbrix.egg-info/requires.txt +1 -1
  12. {hebbrix-2.6.0rc4 → hebbrix-2.6.1}/pyproject.toml +3 -3
  13. hebbrix-2.6.1/tests/test_advisor_integrity.py +276 -0
  14. {hebbrix-2.6.0rc4 → hebbrix-2.6.1}/tests/test_outcome_advice_resource.py +20 -0
  15. hebbrix-2.6.1/tests/test_release_version.py +22 -0
  16. hebbrix-2.6.0rc4/tests/test_release_version.py +0 -12
  17. {hebbrix-2.6.0rc4 → hebbrix-2.6.1}/LICENSE +0 -0
  18. {hebbrix-2.6.0rc4 → hebbrix-2.6.1}/MANIFEST.in +0 -0
  19. {hebbrix-2.6.0rc4 → hebbrix-2.6.1}/contracts/openapi-routes.json +0 -0
  20. {hebbrix-2.6.0rc4 → hebbrix-2.6.1}/docs/native-experience-operations.md +0 -0
  21. {hebbrix-2.6.0rc4 → hebbrix-2.6.1}/examples/native_reflection_worker.py +0 -0
  22. {hebbrix-2.6.0rc4 → hebbrix-2.6.1}/hebbrix/chat.py +0 -0
  23. {hebbrix-2.6.0rc4 → hebbrix-2.6.1}/hebbrix/exceptions.py +0 -0
  24. {hebbrix-2.6.0rc4 → hebbrix-2.6.1}/hebbrix/execution.py +0 -0
  25. {hebbrix-2.6.0rc4 → hebbrix-2.6.1}/hebbrix/experience.py +0 -0
  26. {hebbrix-2.6.0rc4 → hebbrix-2.6.1}/hebbrix/models.py +0 -0
  27. {hebbrix-2.6.0rc4 → hebbrix-2.6.1}/hebbrix/openai_reflection.py +0 -0
  28. {hebbrix-2.6.0rc4 → hebbrix-2.6.1}/hebbrix/reflection_worker.py +0 -0
  29. {hebbrix-2.6.0rc4 → hebbrix-2.6.1}/hebbrix/workflow.py +0 -0
  30. {hebbrix-2.6.0rc4 → hebbrix-2.6.1}/hebbrix.egg-info/dependency_links.txt +0 -0
  31. {hebbrix-2.6.0rc4 → hebbrix-2.6.1}/hebbrix.egg-info/entry_points.txt +0 -0
  32. {hebbrix-2.6.0rc4 → hebbrix-2.6.1}/hebbrix.egg-info/top_level.txt +0 -0
  33. {hebbrix-2.6.0rc4 → hebbrix-2.6.1}/setup.cfg +0 -0
  34. {hebbrix-2.6.0rc4 → hebbrix-2.6.1}/tests/test_advanced_resources.py +0 -0
  35. {hebbrix-2.6.0rc4 → hebbrix-2.6.1}/tests/test_evidence_loop_resource.py +0 -0
  36. {hebbrix-2.6.0rc4 → hebbrix-2.6.1}/tests/test_evidence_safety_boundary.py +0 -0
  37. {hebbrix-2.6.0rc4 → hebbrix-2.6.1}/tests/test_execution_permission.py +0 -0
  38. {hebbrix-2.6.0rc4 → hebbrix-2.6.1}/tests/test_experience_request.py +0 -0
  39. {hebbrix-2.6.0rc4 → hebbrix-2.6.1}/tests/test_experience_workflow.py +0 -0
  40. {hebbrix-2.6.0rc4 → hebbrix-2.6.1}/tests/test_followup_helpers.py +0 -0
  41. {hebbrix-2.6.0rc4 → hebbrix-2.6.1}/tests/test_memories_resource.py +0 -0
  42. {hebbrix-2.6.0rc4 → hebbrix-2.6.1}/tests/test_openai_reflection.py +0 -0
  43. {hebbrix-2.6.0rc4 → hebbrix-2.6.1}/tests/test_openapi_parity.py +0 -0
  44. {hebbrix-2.6.0rc4 → hebbrix-2.6.1}/tests/test_procedural_resource.py +0 -0
  45. {hebbrix-2.6.0rc4 → hebbrix-2.6.1}/tests/test_reflection_worker_cli.py +0 -0
  46. {hebbrix-2.6.0rc4 → hebbrix-2.6.1}/tests/test_sync_client.py +0 -0
  47. {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.0rc4
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 :: 4 - Beta
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>=0.25.0
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.0rc2 prerelease**. The new policy configuration and
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 prerelease SDK does not enable autonomous execution. ASK/REVIEW/ACT is advice;
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.0rc2
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.0rc2 prerelease**. The new policy configuration and
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 prerelease SDK does not enable autonomous execution. ASK/REVIEW/ACT is advice;
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.0rc2
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
@@ -5,7 +5,7 @@ production OpenAPI and ``GET /v1/users/me/capabilities``. The experimental
5
5
  World Model is intentionally absent from this release.
6
6
  """
7
7
 
8
- __version__ = "2.6.0rc4"
8
+ __version__ = "2.6.1"
9
9
  __author__ = "Hebbrix Team"
10
10
  __license__ = "MIT"
11
11
 
@@ -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
+ )
@@ -93,7 +93,7 @@ class MemoryClient:
93
93
  """Get request headers."""
94
94
  headers = {
95
95
  "Content-Type": "application/json",
96
- "User-Agent": "hebbrix-python/2.6.0rc4",
96
+ "User-Agent": "hebbrix-python/2.6.1",
97
97
  }
98
98
 
99
99
  if self.api_key:
@@ -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) -> Dict[str, Any]:
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, configuration=dict(actions=actions)))
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) -> Dict[str, Any]:
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
- No tool execution or outcome is recorded by this helper.
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
- selection = await advisor(card)
1413
- if not isinstance(selection, dict) or set(selection) != {
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) -> Dict[str, Any]:
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).items() if v is not None})
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) -> Dict[str, Any]:
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, configuration=dict(actions=actions)))
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) -> Dict[str, Any]:
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
- No tool execution or outcome is recorded by this helper.
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
- selection = advisor(card)
891
- if not isinstance(selection, dict) or set(selection) != {
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
- collection_id: Optional[str] = None, user_id: Optional[str] = None) -> Dict[str, Any]:
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).items() if v is not None})
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.0rc4",
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.0rc4
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 :: 4 - Beta
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>=0.25.0
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.0rc2 prerelease**. The new policy configuration and
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 prerelease SDK does not enable autonomous execution. ASK/REVIEW/ACT is advice;
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.0rc2
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
@@ -1,4 +1,4 @@
1
- httpx>=0.25.0
1
+ httpx<1,>=0.25.0
2
2
  requests>=2.31.0
3
3
 
4
4
  [dev]
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "hebbrix"
7
- version = "2.6.0rc4"
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 :: 4 - Beta",
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