agent-learning 0.4.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. agent_learning/__init__.py +160 -0
  2. agent_learning/_version.py +3 -0
  3. agent_learning/capture.py +271 -0
  4. agent_learning/classifiers/__init__.py +37 -0
  5. agent_learning/classifiers/base.py +185 -0
  6. agent_learning/classifiers/router.py +236 -0
  7. agent_learning/classifiers/scorers/__init__.py +34 -0
  8. agent_learning/classifiers/scorers/_base.py +162 -0
  9. agent_learning/classifiers/scorers/adherence.py +26 -0
  10. agent_learning/classifiers/scorers/completion.py +26 -0
  11. agent_learning/classifiers/scorers/intent.py +25 -0
  12. agent_learning/cli.py +386 -0
  13. agent_learning/config.py +524 -0
  14. agent_learning/learners/__init__.py +6 -0
  15. agent_learning/learners/base.py +38 -0
  16. agent_learning/learners/reinforce.py +153 -0
  17. agent_learning/metrics/__init__.py +22 -0
  18. agent_learning/metrics/base.py +233 -0
  19. agent_learning/metrics/intent_resolution.py +50 -0
  20. agent_learning/metrics/registry.py +42 -0
  21. agent_learning/metrics/task_adherence.py +42 -0
  22. agent_learning/metrics/task_completion.py +54 -0
  23. agent_learning/policy/__init__.py +7 -0
  24. agent_learning/policy/base.py +59 -0
  25. agent_learning/policy/contextual_softmax.py +243 -0
  26. agent_learning/policy/softmax_bandit.py +157 -0
  27. agent_learning/py.typed +1 -0
  28. agent_learning/rewards/__init__.py +6 -0
  29. agent_learning/rewards/shaping.py +121 -0
  30. agent_learning/rewards/writer.py +130 -0
  31. agent_learning/scorers/__init__.py +187 -0
  32. agent_learning/scorers/base.py +49 -0
  33. agent_learning/scorers/llm/__init__.py +19 -0
  34. agent_learning/scorers/llm/_base.py +126 -0
  35. agent_learning/scorers/llm/adherence.py +21 -0
  36. agent_learning/scorers/llm/completion.py +21 -0
  37. agent_learning/scorers/llm/intent.py +21 -0
  38. agent_learning/scorers/nlp/__init__.py +16 -0
  39. agent_learning/scorers/nlp/_base.py +99 -0
  40. agent_learning/scorers/nlp/adherence.py +17 -0
  41. agent_learning/scorers/nlp/completion.py +17 -0
  42. agent_learning/scorers/nlp/intent.py +17 -0
  43. agent_learning/scorers/nlp_text/__init__.py +26 -0
  44. agent_learning/scorers/nlp_text/_base.py +234 -0
  45. agent_learning/scorers/nlp_text/adherence.py +94 -0
  46. agent_learning/scorers/nlp_text/completion.py +91 -0
  47. agent_learning/scorers/nlp_text/intent.py +59 -0
  48. agent_learning/scorers/slm/__init__.py +25 -0
  49. agent_learning/scorers/slm/_base.py +292 -0
  50. agent_learning/scorers/slm/adherence.py +98 -0
  51. agent_learning/scorers/slm/completion.py +111 -0
  52. agent_learning/scorers/slm/intent.py +80 -0
  53. agent_learning/scorers/stdlib/__init__.py +38 -0
  54. agent_learning/scorers/stdlib/_text.py +87 -0
  55. agent_learning/scorers/stdlib/adherence.py +157 -0
  56. agent_learning/scorers/stdlib/completion.py +117 -0
  57. agent_learning/scorers/stdlib/intent.py +182 -0
  58. agent_learning/storage/__init__.py +14 -0
  59. agent_learning/storage/base.py +156 -0
  60. agent_learning/storage/cosmos.py +506 -0
  61. agent_learning/storage/local.py +353 -0
  62. agent_learning/storage/memory.py +209 -0
  63. agent_learning/training/__init__.py +5 -0
  64. agent_learning/training/runner.py +172 -0
  65. agent_learning/types.py +507 -0
  66. agent_learning-0.4.1.dist-info/METADATA +82 -0
  67. agent_learning-0.4.1.dist-info/RECORD +71 -0
  68. agent_learning-0.4.1.dist-info/WHEEL +5 -0
  69. agent_learning-0.4.1.dist-info/entry_points.txt +2 -0
  70. agent_learning-0.4.1.dist-info/licenses/LICENSE +21 -0
  71. agent_learning-0.4.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,243 @@
1
+ """Contextual softmax bandit policy.
2
+
3
+ The policy stores a weight matrix ``W`` of shape ``(K, d)`` where ``K``
4
+ is the number of actions and ``d`` is the feature dimension. Action
5
+ logits at inference time are computed as ``logits = W @ phi`` for a
6
+ caller-supplied feature vector ``phi`` of length ``d``. A numerically
7
+ stable softmax then turns the logits into a probability distribution
8
+ the policy samples from.
9
+
10
+ The weight matrix lives in :attr:`PolicySnapshot.metadata`:
11
+
12
+ - ``metadata["feature_dim"]`` (int) - the dimension ``d``.
13
+ - ``metadata["context_weights"]`` (dict[action_id, list[float]]) -
14
+ one length-``d`` row per action.
15
+
16
+ Using ``metadata`` keeps the persisted snapshot schema unchanged
17
+ (the existing ``logits`` map stays untouched and unused). Storage
18
+ and Cosmos round-trip continue to work with no migration.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import math
24
+ import random
25
+ from typing import Any, Dict, List, Optional, Sequence
26
+
27
+ import numpy as np
28
+
29
+ from ..types import Action, PolicySnapshot
30
+ from .base import Decision, Policy
31
+
32
+
33
+ _METADATA_WEIGHTS_KEY = "context_weights"
34
+ _METADATA_DIM_KEY = "feature_dim"
35
+
36
+
37
+ class ContextualSoftmaxPolicy(Policy):
38
+ """Linear contextual softmax over a fixed action set.
39
+
40
+ Construction:
41
+ policy = ContextualSoftmaxPolicy.from_actions(
42
+ actions, feature_dim=25, agent_id="dq"
43
+ )
44
+
45
+ Continuation from a snapshot:
46
+ policy = ContextualSoftmaxPolicy.from_snapshot(snapshot)
47
+ """
48
+
49
+ def __init__(
50
+ self,
51
+ snapshot: PolicySnapshot,
52
+ *,
53
+ rng: Optional[random.Random] = None,
54
+ max_weight_abs: float = 10.0,
55
+ ) -> None:
56
+ if not snapshot.actions:
57
+ raise ValueError("ContextualSoftmaxPolicy requires at least one action.")
58
+ feature_dim = int(snapshot.metadata.get(_METADATA_DIM_KEY, 0))
59
+ if feature_dim <= 0:
60
+ raise ValueError(
61
+ "ContextualSoftmaxPolicy requires snapshot.metadata['feature_dim'] > 0."
62
+ )
63
+ weights = snapshot.metadata.get(_METADATA_WEIGHTS_KEY)
64
+ if not isinstance(weights, dict):
65
+ weights = {}
66
+ # Materialise a row of zeros for any action missing a weight row.
67
+ for action in snapshot.actions:
68
+ row = weights.get(action.id)
69
+ if row is None or len(row) != feature_dim:
70
+ weights[action.id] = [0.0] * feature_dim
71
+ else:
72
+ weights[action.id] = [float(x) for x in row]
73
+ snapshot.metadata[_METADATA_WEIGHTS_KEY] = weights
74
+ snapshot.metadata[_METADATA_DIM_KEY] = feature_dim
75
+
76
+ self._snapshot = snapshot
77
+ self._rng = rng or random.Random()
78
+ self._max_weight_abs = float(max_weight_abs)
79
+ self._feature_dim = feature_dim
80
+
81
+ # ------------------------------------------------------------------
82
+ # Constructors
83
+ # ------------------------------------------------------------------
84
+
85
+ @classmethod
86
+ def from_actions(
87
+ cls,
88
+ actions: Sequence[Action],
89
+ *,
90
+ feature_dim: int,
91
+ agent_id: str = "default",
92
+ task_id: str = "default",
93
+ rng: Optional[random.Random] = None,
94
+ max_weight_abs: float = 10.0,
95
+ initial_weights: Optional[Dict[str, Sequence[float]]] = None,
96
+ ) -> "ContextualSoftmaxPolicy":
97
+ if feature_dim <= 0:
98
+ raise ValueError("feature_dim must be a positive integer.")
99
+ weights: Dict[str, List[float]] = {
100
+ a.id: [0.0] * feature_dim for a in actions
101
+ }
102
+ if initial_weights:
103
+ for action_id, row in initial_weights.items():
104
+ if action_id in weights:
105
+ row_list = [float(x) for x in row]
106
+ if len(row_list) != feature_dim:
107
+ raise ValueError(
108
+ f"initial_weights['{action_id}'] has length "
109
+ f"{len(row_list)}, expected {feature_dim}."
110
+ )
111
+ weights[action_id] = row_list
112
+ snapshot = PolicySnapshot(
113
+ agent_id=agent_id,
114
+ task_id=task_id,
115
+ version=0,
116
+ actions=list(actions),
117
+ logits={a.id: 0.0 for a in actions}, # kept for back-compat, unused
118
+ metadata={
119
+ _METADATA_DIM_KEY: int(feature_dim),
120
+ _METADATA_WEIGHTS_KEY: weights,
121
+ "policy_kind": "contextual_softmax",
122
+ },
123
+ )
124
+ return cls(snapshot, rng=rng, max_weight_abs=max_weight_abs)
125
+
126
+ @classmethod
127
+ def from_snapshot(
128
+ cls,
129
+ snapshot: PolicySnapshot,
130
+ *,
131
+ rng: Optional[random.Random] = None,
132
+ max_weight_abs: float = 10.0,
133
+ ) -> "ContextualSoftmaxPolicy":
134
+ return cls(snapshot, rng=rng, max_weight_abs=max_weight_abs)
135
+
136
+ # ------------------------------------------------------------------
137
+ # Policy interface
138
+ # ------------------------------------------------------------------
139
+
140
+ def actions(self) -> Sequence[Action]:
141
+ return tuple(self._snapshot.actions)
142
+
143
+ @property
144
+ def feature_dim(self) -> int:
145
+ return self._feature_dim
146
+
147
+ def _weights_matrix(self) -> np.ndarray:
148
+ rows = [
149
+ self._snapshot.metadata[_METADATA_WEIGHTS_KEY][a.id]
150
+ for a in self._snapshot.actions
151
+ ]
152
+ return np.asarray(rows, dtype=np.float64)
153
+
154
+ def probabilities(self, state: Optional[Any] = None) -> List[float]:
155
+ """Softmax over ``W @ phi`` in deterministic action order.
156
+
157
+ If ``state`` is None the policy returns the uniform distribution
158
+ over actions (equivalent to ``phi == 0``).
159
+ """
160
+ n_actions = len(self._snapshot.actions)
161
+ if state is None:
162
+ uniform = 1.0 / n_actions
163
+ return [uniform] * n_actions
164
+ phi = np.asarray(state, dtype=np.float64).reshape(-1)
165
+ if phi.shape[0] != self._feature_dim:
166
+ raise ValueError(
167
+ f"State vector has length {phi.shape[0]}, expected "
168
+ f"{self._feature_dim}."
169
+ )
170
+ logits = self._weights_matrix() @ phi
171
+ logits -= logits.max() # numerical stability
172
+ exp = np.exp(logits)
173
+ denom = float(exp.sum())
174
+ if denom <= 0.0 or not np.isfinite(denom): # pragma: no cover
175
+ uniform = 1.0 / n_actions
176
+ return [uniform] * n_actions
177
+ return (exp / denom).tolist()
178
+
179
+ def choose(self, state: Optional[Any] = None) -> Decision:
180
+ probs = self.probabilities(state)
181
+ idx = self._weighted_sample(probs)
182
+ chosen = self._snapshot.actions[idx]
183
+ prob = max(probs[idx], 1e-12)
184
+ return Decision(action=chosen, logprob=math.log(prob), probabilities=probs)
185
+
186
+ def snapshot(self) -> PolicySnapshot:
187
+ return PolicySnapshot.from_dict(self._snapshot.to_dict())
188
+
189
+ def apply_update(
190
+ self,
191
+ deltas: Dict[str, Any],
192
+ *,
193
+ baseline: float,
194
+ episodes_seen: int,
195
+ ) -> None:
196
+ """Apply additive updates to the context weights, clip, and bump version.
197
+
198
+ ``deltas`` accepts two shapes for backward compatibility with
199
+ the marginal :class:`SoftmaxPolicy` API:
200
+
201
+ - ``{action_id: float}`` - apply ``delta`` to every dimension of
202
+ that action's weight row (rare; useful only as a smoke test).
203
+ - ``{action_id: Sequence[float]}`` of length ``feature_dim`` -
204
+ element-wise add to the weight row.
205
+ """
206
+ weights: Dict[str, List[float]] = self._snapshot.metadata[_METADATA_WEIGHTS_KEY]
207
+ for action_id, delta in deltas.items():
208
+ if action_id not in weights:
209
+ continue
210
+ current = np.asarray(weights[action_id], dtype=np.float64)
211
+ if isinstance(delta, (int, float)):
212
+ current = current + float(delta)
213
+ else:
214
+ delta_arr = np.asarray(delta, dtype=np.float64).reshape(-1)
215
+ if delta_arr.shape[0] != self._feature_dim:
216
+ raise ValueError(
217
+ f"Delta for '{action_id}' has length "
218
+ f"{delta_arr.shape[0]}, expected {self._feature_dim}."
219
+ )
220
+ current = current + delta_arr
221
+ current = np.clip(current, -self._max_weight_abs, self._max_weight_abs)
222
+ weights[action_id] = current.tolist()
223
+ self._snapshot.metadata[_METADATA_WEIGHTS_KEY] = weights
224
+ self._snapshot.baseline = float(baseline)
225
+ self._snapshot.episodes_seen = int(episodes_seen)
226
+ self._snapshot.updates_applied += 1
227
+ self._snapshot.advance_version()
228
+
229
+ # ------------------------------------------------------------------
230
+ # Internals
231
+ # ------------------------------------------------------------------
232
+
233
+ def _weighted_sample(self, probs: Sequence[float]) -> int:
234
+ roll = self._rng.random()
235
+ cumulative = 0.0
236
+ for idx, p in enumerate(probs):
237
+ cumulative += p
238
+ if roll <= cumulative:
239
+ return idx
240
+ return len(probs) - 1 # pragma: no cover - rounding tail
241
+
242
+
243
+ __all__ = ["ContextualSoftmaxPolicy"]
@@ -0,0 +1,157 @@
1
+ """Softmax bandit policy over a fixed set of discrete actions.
2
+
3
+ The policy stores one logit per action. Action probabilities are
4
+ computed via a numerically stable softmax. The action space is
5
+ fixed at construction time; the learner mutates only the logits and
6
+ the EMA baseline.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import math
12
+ import random
13
+ from typing import Any, Dict, List, Optional, Sequence
14
+
15
+ import numpy as np
16
+
17
+ from ..types import Action, PolicySnapshot
18
+ from .base import Decision, Policy
19
+
20
+
21
+ class SoftmaxPolicy(Policy):
22
+ """Discrete softmax policy with one logit per action.
23
+
24
+ Construction:
25
+ policy = SoftmaxPolicy.from_actions(actions, agent_id="dq")
26
+
27
+ Continuation from a snapshot (e.g. after process restart):
28
+ policy = SoftmaxPolicy.from_snapshot(snapshot)
29
+ """
30
+
31
+ def __init__(
32
+ self,
33
+ snapshot: PolicySnapshot,
34
+ *,
35
+ rng: Optional[random.Random] = None,
36
+ max_logit_abs: float = 10.0,
37
+ ) -> None:
38
+ if not snapshot.actions:
39
+ raise ValueError("SoftmaxPolicy requires at least one action.")
40
+ self._snapshot = snapshot
41
+ self._rng = rng or random.Random()
42
+ self._max_logit_abs = max_logit_abs
43
+ # Make sure every action has a logit
44
+ for action in snapshot.actions:
45
+ snapshot.logits.setdefault(action.id, 0.0)
46
+
47
+ # ------------------------------------------------------------------
48
+ # Constructors
49
+ # ------------------------------------------------------------------
50
+
51
+ @classmethod
52
+ def from_actions(
53
+ cls,
54
+ actions: Sequence[Action],
55
+ *,
56
+ agent_id: str = "default",
57
+ task_id: str = "default",
58
+ rng: Optional[random.Random] = None,
59
+ max_logit_abs: float = 10.0,
60
+ initial_logits: Optional[Dict[str, float]] = None,
61
+ ) -> "SoftmaxPolicy":
62
+ logits = {a.id: 0.0 for a in actions}
63
+ if initial_logits:
64
+ for action_id, value in initial_logits.items():
65
+ if action_id in logits:
66
+ logits[action_id] = float(value)
67
+ snapshot = PolicySnapshot(
68
+ agent_id=agent_id,
69
+ task_id=task_id,
70
+ version=0,
71
+ actions=list(actions),
72
+ logits=logits,
73
+ )
74
+ return cls(snapshot, rng=rng, max_logit_abs=max_logit_abs)
75
+
76
+ @classmethod
77
+ def from_snapshot(
78
+ cls,
79
+ snapshot: PolicySnapshot,
80
+ *,
81
+ rng: Optional[random.Random] = None,
82
+ max_logit_abs: float = 10.0,
83
+ ) -> "SoftmaxPolicy":
84
+ return cls(snapshot, rng=rng, max_logit_abs=max_logit_abs)
85
+
86
+ # ------------------------------------------------------------------
87
+ # Policy interface
88
+ # ------------------------------------------------------------------
89
+
90
+ def actions(self) -> Sequence[Action]:
91
+ return tuple(self._snapshot.actions)
92
+
93
+ def probabilities(self) -> List[float]:
94
+ """Softmax over the action logits in deterministic order."""
95
+ logits = np.array(
96
+ [self._snapshot.logits[a.id] for a in self._snapshot.actions],
97
+ dtype=np.float64,
98
+ )
99
+ logits -= logits.max() # numerical stability
100
+ exp = np.exp(logits)
101
+ denom = exp.sum()
102
+ if denom <= 0.0 or not np.isfinite(denom): # pragma: no cover
103
+ uniform = 1.0 / len(self._snapshot.actions)
104
+ return [uniform] * len(self._snapshot.actions)
105
+ probs = exp / denom
106
+ return probs.tolist()
107
+
108
+ def choose(self, state: Optional[Any] = None) -> Decision:
109
+ # ``state`` is accepted for interface compatibility with
110
+ # contextual policies and is intentionally ignored: this is a
111
+ # marginal softmax that conditions only on its stored logits.
112
+ del state
113
+ probs = self.probabilities()
114
+ idx = self._weighted_sample(probs)
115
+ chosen = self._snapshot.actions[idx]
116
+ # log probability is clamped to avoid -inf for tiny probabilities
117
+ prob = max(probs[idx], 1e-12)
118
+ return Decision(action=chosen, logprob=math.log(prob), probabilities=probs)
119
+
120
+ def snapshot(self) -> PolicySnapshot:
121
+ return PolicySnapshot.from_dict(self._snapshot.to_dict())
122
+
123
+ def apply_update(
124
+ self,
125
+ deltas: Dict[str, float],
126
+ *,
127
+ baseline: float,
128
+ episodes_seen: int,
129
+ ) -> None:
130
+ """Apply per-action logit deltas, clip, and bump version."""
131
+ for action_id, delta in deltas.items():
132
+ if action_id not in self._snapshot.logits:
133
+ continue
134
+ new_value = self._snapshot.logits[action_id] + float(delta)
135
+ new_value = max(-self._max_logit_abs, min(self._max_logit_abs, new_value))
136
+ self._snapshot.logits[action_id] = new_value
137
+ self._snapshot.baseline = float(baseline)
138
+ self._snapshot.episodes_seen = int(episodes_seen)
139
+ self._snapshot.updates_applied += 1
140
+ self._snapshot.advance_version()
141
+
142
+ # ------------------------------------------------------------------
143
+ # Internals
144
+ # ------------------------------------------------------------------
145
+
146
+ def _weighted_sample(self, probs: Sequence[float]) -> int:
147
+ """Sample an index from ``probs`` using the policy's RNG."""
148
+ roll = self._rng.random()
149
+ cumulative = 0.0
150
+ for idx, p in enumerate(probs):
151
+ cumulative += p
152
+ if roll <= cumulative:
153
+ return idx
154
+ return len(probs) - 1 # pragma: no cover - rounding tail
155
+
156
+
157
+ __all__ = ["SoftmaxPolicy"]
@@ -0,0 +1 @@
1
+ """Marker file used by PEP 561 type checkers."""
@@ -0,0 +1,6 @@
1
+ """Reward shaping (metrics → scalar reward) and reward persistence."""
2
+
3
+ from .shaping import RewardShaper, shape_episode_reward
4
+ from .writer import RewardWriter
5
+
6
+ __all__ = ["RewardShaper", "RewardWriter", "shape_episode_reward"]
@@ -0,0 +1,121 @@
1
+ """Reward shaping: combine multiple metric results into one scalar reward.
2
+
3
+ The shaper turns each metric's normalised score (in ``[0, 1]``) into
4
+ a signed contribution (in ``[-1, 1]``) and sums them with the
5
+ configured weights. Additional behavioural penalties (latency, cost)
6
+ can be applied after the metric sum. The final reward is clamped to
7
+ ``[-1, 1]`` so it is always safe to feed into a policy-gradient
8
+ update.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass
14
+ from typing import Iterable, List, Optional
15
+
16
+ from ..config import ShapingConfig
17
+ from ..types import Episode, MetricName, MetricResult
18
+
19
+
20
+ @dataclass
21
+ class ShapedReward:
22
+ """Result of shaping a metric collection into a scalar reward.
23
+
24
+ ``metric_contributions`` exposes the signed contribution of every
25
+ metric individually so callers can persist per-metric rewards
26
+ alongside the aggregate.
27
+ """
28
+
29
+ value: float
30
+ metric_contributions: List[tuple]
31
+ penalties: List[tuple]
32
+
33
+
34
+ class RewardShaper:
35
+ """Combine multiple :class:`MetricResult` into a scalar in ``[-1, 1]``."""
36
+
37
+ def __init__(self, config: Optional[ShapingConfig] = None) -> None:
38
+ self._config = config or ShapingConfig()
39
+
40
+ @property
41
+ def config(self) -> ShapingConfig:
42
+ return self._config
43
+
44
+ def shape(
45
+ self,
46
+ results: Iterable[MetricResult],
47
+ *,
48
+ latency_ms: Optional[int] = None,
49
+ routing_correct: Optional[bool] = None,
50
+ hallucinated_class: bool = False,
51
+ ) -> ShapedReward:
52
+ """Combine scores + behavioural signals into a single scalar.
53
+
54
+ Args:
55
+ results: Per-score metric outputs.
56
+ latency_ms: Episode latency in milliseconds; if above the
57
+ configured threshold the latency penalty is applied.
58
+ routing_correct: ``True`` if the calling system routed the
59
+ request to a class id in the allowed set
60
+ (→ add ``route_correct_reward``); ``False`` if it
61
+ picked a class outside the allowed set (→ add
62
+ ``route_wrong_penalty``, which is negative). ``None``
63
+ disables the term entirely (no signal either way).
64
+ hallucinated_class: ``True`` if the rendered output
65
+ references an entity/class id not in the allowed set
66
+ (→ add ``hallucinated_class_penalty``).
67
+ """
68
+ weights = {
69
+ MetricName.INTENT_RESOLUTION: self._config.intent_resolution_weight,
70
+ MetricName.TASK_ADHERENCE: self._config.task_adherence_weight,
71
+ MetricName.TASK_COMPLETION: self._config.task_completion_weight,
72
+ }
73
+
74
+ contributions: List[tuple] = []
75
+ total = 0.0
76
+ for r in results:
77
+ if r.normalized is None or r.status != "completed":
78
+ continue
79
+ weight = weights.get(r.metric, 0.0)
80
+ # Map [0, 1] -> [-1, 1]
81
+ signed = 2.0 * r.normalized - 1.0
82
+ contribution = weight * signed
83
+ total += contribution
84
+ contributions.append((r.metric, weight, signed, contribution))
85
+
86
+ penalties: List[tuple] = []
87
+ if (
88
+ latency_ms is not None
89
+ and latency_ms > self._config.latency_penalty_threshold_ms
90
+ ):
91
+ total += self._config.latency_penalty_value
92
+ penalties.append(("latency", latency_ms, self._config.latency_penalty_value))
93
+
94
+ if routing_correct is True:
95
+ total += self._config.route_correct_reward
96
+ penalties.append(("route_correct", True, self._config.route_correct_reward))
97
+ elif routing_correct is False:
98
+ total += self._config.route_wrong_penalty
99
+ penalties.append(("route_wrong", False, self._config.route_wrong_penalty))
100
+
101
+ if hallucinated_class:
102
+ total += self._config.hallucinated_class_penalty
103
+ penalties.append(
104
+ ("hallucinated_class", True, self._config.hallucinated_class_penalty)
105
+ )
106
+
107
+ clamped = max(-1.0, min(1.0, total))
108
+ return ShapedReward(value=clamped, metric_contributions=contributions, penalties=penalties)
109
+
110
+
111
+ def shape_episode_reward(
112
+ episode: Episode,
113
+ results: Iterable[MetricResult],
114
+ config: Optional[ShapingConfig] = None,
115
+ ) -> ShapedReward:
116
+ """Convenience wrapper that pulls latency from the episode automatically."""
117
+ shaper = RewardShaper(config)
118
+ return shaper.shape(results, latency_ms=episode.request_latency_ms)
119
+
120
+
121
+ __all__ = ["RewardShaper", "ShapedReward", "shape_episode_reward"]
@@ -0,0 +1,130 @@
1
+ """Persist metric results and aggregate rewards for an episode."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import Iterable, List, Optional
7
+
8
+ from ..storage.base import LearningStore
9
+ from ..storage.cosmos import get_default_store
10
+ from ..types import Episode, MetricName, MetricResult, Reward, RewardSource
11
+ from .shaping import ShapedReward
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ class RewardWriter:
17
+ """Translate metric results into stored :class:`Reward` records.
18
+
19
+ The writer stores one ``Reward`` per metric (``source=METRIC``)
20
+ plus a single aggregate reward (``source=AGGREGATE``) that the
21
+ learner consumes. Storing per-metric values keeps the learning history
22
+ complete and lets downstream analytics decompose the aggregate.
23
+ """
24
+
25
+ def __init__(self, store: Optional[LearningStore] = None) -> None:
26
+ self._store = store
27
+
28
+ @property
29
+ def store(self) -> LearningStore:
30
+ if self._store is None:
31
+ self._store = get_default_store()
32
+ return self._store
33
+
34
+ def write(
35
+ self,
36
+ episode: Episode,
37
+ results: Iterable[MetricResult],
38
+ shaped: ShapedReward,
39
+ *,
40
+ rubric: Optional[str] = None,
41
+ ) -> List[Reward]:
42
+ """Persist metric and aggregate rewards. Returns the stored rows."""
43
+ stored: List[Reward] = []
44
+ results_list = list(results)
45
+
46
+ # 1) Persist the raw metric results themselves (richer than rewards)
47
+ try:
48
+ self.store.store_metric_results(episode.id, episode.agent_id, results_list)
49
+ except Exception as exc: # pragma: no cover - persistence error path
50
+ logger.warning("Failed to persist metric results for %s: %s", episode.id, exc)
51
+
52
+ # 2) Per-metric reward records (signed contributions)
53
+ contributions = {m: signed for m, _w, signed, _c in shaped.metric_contributions}
54
+ for result in results_list:
55
+ if result.status != "completed" or result.normalized is None:
56
+ continue
57
+ signed_value = contributions.get(
58
+ result.metric,
59
+ 2.0 * result.normalized - 1.0,
60
+ )
61
+ reward = Reward(
62
+ episode_id=episode.id,
63
+ agent_id=episode.agent_id,
64
+ source=RewardSource.METRIC,
65
+ value=max(-1.0, min(1.0, signed_value)),
66
+ raw_value=result.score,
67
+ metric=result.metric,
68
+ rubric=rubric or result.metric.value,
69
+ evaluator=result.evaluator,
70
+ metadata={"reason": result.reason} if result.reason else {},
71
+ )
72
+ try:
73
+ self.store.store_reward(reward)
74
+ stored.append(reward)
75
+ except Exception as exc: # pragma: no cover
76
+ logger.warning(
77
+ "Failed to persist %s reward for %s: %s",
78
+ result.metric.value,
79
+ episode.id,
80
+ exc,
81
+ )
82
+
83
+ # 3) Latency / cost penalty rewards (kept separate for analytics)
84
+ for kind, raw_value, value in shaped.penalties:
85
+ reward = Reward(
86
+ episode_id=episode.id,
87
+ agent_id=episode.agent_id,
88
+ source=RewardSource.LATENCY_PENALTY
89
+ if kind == "latency"
90
+ else RewardSource.COST_PENALTY,
91
+ value=max(-1.0, min(1.0, value)),
92
+ raw_value=raw_value,
93
+ rubric=kind,
94
+ )
95
+ try:
96
+ self.store.store_reward(reward)
97
+ stored.append(reward)
98
+ except Exception as exc: # pragma: no cover
99
+ logger.warning("Failed to persist %s penalty for %s: %s", kind, episode.id, exc)
100
+
101
+ # 4) Aggregate reward consumed by the learner
102
+ aggregate = Reward(
103
+ episode_id=episode.id,
104
+ agent_id=episode.agent_id,
105
+ source=RewardSource.AGGREGATE,
106
+ value=shaped.value,
107
+ rubric=rubric or "aggregate",
108
+ metadata={
109
+ "metric_contributions": [
110
+ {
111
+ "metric": m.value if isinstance(m, MetricName) else str(m),
112
+ "weight": w,
113
+ "signed": signed,
114
+ "contribution": c,
115
+ }
116
+ for m, w, signed, c in shaped.metric_contributions
117
+ ],
118
+ "penalties": [{"kind": k, "raw": r, "value": v} for k, r, v in shaped.penalties],
119
+ },
120
+ )
121
+ try:
122
+ self.store.store_reward(aggregate)
123
+ stored.append(aggregate)
124
+ except Exception as exc: # pragma: no cover
125
+ logger.warning("Failed to persist aggregate reward for %s: %s", episode.id, exc)
126
+
127
+ return stored
128
+
129
+
130
+ __all__ = ["RewardWriter"]