TDValueField¶
A DecimalField subclass that adds one operation the ordinary save path cannot express: an atomic temporal-difference update of a single hash field, performed server-side in one Lua script.
Overview¶
TDValueField stores a learned value — a Q-value, in reinforcement-learning terms — in the model hash exactly as a plain DecimalField does. Nothing lives outside the hash. What it adds is td_update(), which applies the TD(0) rule
as a single atomic read-modify-write. The read and the write happen inside the same script, so two processes updating the same instance serialize at the server instead of racing through a client-side HGET / compute / HSET round trip, where the later writer silently discards the earlier one's update.
The field was extracted from popoto.recipes.policy_cache, which owned the script directly (issue #647, part of the #630 series). A recipe composes primitives; it should not own one. update_q_value() remains as a convenience wrapper with its original signature — see PolicyCache.
Parameters¶
None. TDValueField takes no constructor arguments beyond what DecimalField accepts, and declares no storage of its own.
The learning rate and discount factor are call-time arguments of td_update(), not field-level configuration. That is deliberate: they are experimental tuning constants (see popoto.fields.constants.Defaults), and a field-declared default would let two processes disagree about the gain schedule with no runtime detection — the same hazard ConfidenceField's evidence_cap carries.
The field must be named q_value
The hash field name is hard-coded inside the Lua script. Parameterizing it would change the script text, hence its SHA, hence the on-wire EVALSHA payload. td_update() therefore refuses any other field name with a ValueError rather than silently updating the wrong column.
Usage¶
from decimal import Decimal
from popoto import Model, KeyField, TDValueField
class Bandit(Model):
agent_id = KeyField()
q_value = TDValueField(default=Decimal("0"))
arm = Bandit(agent_id="agent-1")
arm.save()
# One TD(0) step. Returns the TD error.
td_error = TDValueField.td_update(arm, "q_value", reward=1.0, max_future_q=0.6)
# => positive when the outcome beat the current estimate
# The in-memory attribute is NOT refreshed — reload to observe the new value.
arm = Bandit.query.get(agent_id="agent-1")
Arguments¶
| Argument | Type | Default | Description |
|---|---|---|---|
model_instance |
Model | — | A saved instance carrying a TDValueField. |
field_name |
str | — | Must be "q_value". |
reward |
float | — | Observed reward signal. |
max_future_q |
float | 0.0 |
Best value available in the next state. 0.0 means terminal — no future state. |
alpha |
float | Defaults.TD_ALPHA (0.1) |
Learning rate: how much new information overrides old. |
gamma |
float | Defaults.TD_GAMMA (0.95) |
Discount factor: importance of future rewards. |
pipeline |
Pipeline | None |
Queue the update on a pipeline. Returns None; the TD error appears in that pipeline's results after execute(). |
Raises ValueError if the instance is unsaved, or if field_name is not a TDValueField named q_value. The guard issues no Redis command before raising.
The in-memory attribute is not refreshed¶
td_update() returns the TD error, not the new value, and does not write back to the Python object. This is intentional: recomputing the new value client-side would duplicate the arithmetic and the encoding this field exists to own, and re-reading it would add a command to a wire sequence that is otherwise exactly one script call. Reload the instance to observe the new value.
Storage and encoding¶
The value is written as the __Decimal__ tagged dict that every DecimalField uses, encoded with cmsgpack inside the script so it is byte-interchangeable with what Python's msgpack encoder produces.
This matters beyond tidiness. DecayingSortedField reads a base_score_field straight out of the member's hash in its own Lua and falls back to 1.0 for any encoding it does not recognize. A PolicyEntry declares expected_value = DecayingSortedField(base_score_field="q_value"), so if TDValueField wrote any other encoding, the decay clock would silently fall back to a magnitude of 1.0 rather than error. Keeping TDValueField a DecimalField subclass — type is Decimal — is what keeps that contract.
Valkey compatibility¶
Core commands only — HGET, HSET, and cmsgpack inside the script. No Redis-module commands (BF., CMS., TOPK., TS., JSON.), so this field behaves identically on Redis and Valkey.
See also¶
- PolicyCache — the recipe that composes this field, and the
update_q_value()wrapper. - DecayingSortedField — reads
q_valueas abase_score_field. - ConfidenceField — the sibling primitive whose per-call tuning argument follows the same rule.