popoto.recipes.memory_telemetry¶
popoto.recipes.memory_telemetry
¶
MemoryTelemetry — turn live assemble() calls into a real-workload benchmark.
Issue #464 (part of #456, Track A). Live agents give us the two things every
offline benchmark lacks: the real query/turn distribution and real outcome
labels. ContextAssembler.assemble() already picks a memory set on every
turn, and ObservationProtocol.on_context_used() already knows whether each
memory was acted / used / dismissed / deferred / contradicted — but that signal
is consumed to nudge confidence/decay and then evaporates. This recipe records
it durably (TTL-bounded, Valkey-safe) so every live agent becomes a continuous,
real-workload benchmark.
Three pieces:
- :class:
AssemblyEvent— one msgpack-backed Popoto model instance perassemble()call, with a mandatory default TTL so telemetry can never grow a store past the 20k-scale posture. Dogfoods the same Redis/Valkey. - :class:
TelemetryRecorder— wraps aContextAssembler(same pattern asAdaptiveAssembler), delegatesassemble(), and writes one event per call. Fail-open: a telemetry error never breaks or measurably slowsassemble(). Overhead is measured and reported. - :func:
report_outcomes— joins the later outcome onto the matching event. - :class:
TelemetryAnalyzer— offline, read-only report generator producing injection-precision, confidence-calibration, and decay-regret reports.
Privacy: content capture is opt-in. The default capture="ids" records
memory ids, ranks, scores, and metadata only — never memory content or query
text. capture="content" (set explicitly in code, per store — never a global
env var or runtime toggle) additionally records content and query text, for
fully-open deployments whose operator asserts the store is publishable.
Namespace note: popoto owns model-keyed, queryable, TTL'd records
(AssemblyEvent:*); the agent stack owns its analytics:* counters. This
is a deliberate divergence, not a third convention.
Example
from popoto.recipes.context_assembler import ContextAssembler from popoto.recipes.memory_telemetry import ( TelemetryRecorder, TelemetryAnalyzer, report_outcomes, )
assembler = ContextAssembler(Memory, score_weights={"relevance": 1.0}) recorder = TelemetryRecorder(assembler) # capture="ids" (private) result = recorder.assemble({"topic": "deploy"}, agent_id="agent-1") event_id = result.metadata["telemetry_event_id"]
... later, when the agent's response is observed ...¶
report_outcomes(event_id, {mem.db_key.redis_key: "acted"})
... offline ...¶
print(TelemetryAnalyzer(agent_id="agent-1").report())
DEFAULT_EVENT_TTL = 7 * 24 * 3600
module-attribute
¶
Default lifetime (seconds) of an AssemblyEvent record: 7 days. Bounds the telemetry store so it can never grow past the 20k-scale posture regardless of call volume. Long enough to join same-week outcomes and run a weekly analyzer pass; short enough that residue self-clears. Recorder may shorten per store.
DEFAULT_SAMPLE_RATE = 1.0
module-attribute
¶
Fraction of assemble() calls recorded. 1.0 = every call. Lower it on hot
paths where the write volume would distort #460's latency numbers; the recorder
reports measured overhead so the operator can pick a rate from evidence.
CALIBRATION_BUCKET_EDGES = (0.2, 0.4, 0.6, 0.8)
module-attribute
¶
Interior edges partitioning injection-time score into 5 calibration buckets ([-inf,0.2), [0.2,0.4), [0.4,0.6), [0.6,0.8), [0.8,inf)). Used only by the offline analyzer to bin the confidence-calibration and decay-regret curves.
AssemblyEvent
¶
Bases: Model
One telemetry record per ContextAssembler.assemble() call.
ids-only by default; content/query_text populated only under
capture="content". TTL-bounded via Meta.ttl (overridable per
instance by the recorder). Queryable by agent_id (partition KeyField).
Fields
event_id: Auto key.
agent_id: Partition — matches assemble(agent_id=...). May be None.
model_name: Name of the model class the assembler queried.
ts: Unix timestamp (float) of the call. Plain field (not a sorted
index) to keep the hot-path write to a single key — the offline
analyzer filters by time in Python over the TTL-bounded corpus.
retrieval_mode: "hybrid" | "lexical" | "composite".
capture: "ids" (default, private) | "content" (opt-in).
injected: List of {key, rank, score, source[, content]} — one per
injected memory, in final rank order. content present only
under capture="content".
budget_tokens / budget_items: Budget consumed by the injection.
corpus_size: Best-effort candidate-store size (may be None).
latency_ms: assemble() wall time.
overhead_ms: Telemetry preparation cost (see TelemetryRecorder).
query_text: The query cue text — populated only under
capture="content".
outcomes: Joined later by :func:report_outcomes; list of
{key, outcome, at}.
Source code in src/popoto/recipes/memory_telemetry.py
TelemetryRecorder
¶
Wrap a ContextAssembler and record one AssemblyEvent per call.
Delegates assemble() unchanged and returns the real
AssemblyResult (with metadata["telemetry_event_id"] added when an
event was written). The telemetry path is fail-open: any error is
caught and logged — it never propagates into assemble().
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
inner
|
A |
required | |
event_model
|
The event Model class. Default :class: |
AssemblyEvent
|
|
capture
|
str
|
|
'ids'
|
sample_rate
|
float
|
Fraction of calls to record in |
DEFAULT_SAMPLE_RATE
|
ttl
|
int
|
Per-instance TTL (seconds) applied to each event. Default
:data: |
DEFAULT_EVENT_TTL
|
rng
|
Random | None
|
Optional |
None
|
Source code in src/popoto/recipes/memory_telemetry.py
159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 | |
overhead_stats
property
¶
Measured telemetry overhead across recorded calls.
Returns a dict {count, mean_ms, p95_ms, max_ms} over the full
per-call _record wall time — building the injected list plus the
Redis save(). Empty history yields zeros. This is the "record the
cost" report the issue requires — so telemetry can be shown not to
distort #460's latency.
Scope note: the emit_trace score-proxy runs inside
inner.assemble(), so its cost is already reflected in each event's
latency_ms (the assemble wall time), not double-counted here. The
per-event overhead_ms field is the preparation cost only (it is
written into the event, so it cannot include that event's own
save()); overhead_stats is the fuller per-call figure.
assemble(query_cues=None, agent_id=None, **kwargs)
¶
Delegate to the wrapped assembler, recording one event per call.
The sampling decision is made before the inner call: only when this
call will be recorded is emit_trace=True forced on the assembler,
so sampled-out calls pay no trace-proxy cost — sample_rate genuinely
protects hot-path latency. A caller that passes its own emit_trace
is respected on sampled-out calls. Returns the inner AssemblyResult
unchanged apart from an added metadata["telemetry_event_id"] when an
event was written.
Source code in src/popoto/recipes/memory_telemetry.py
TelemetryAnalyzer
¶
Offline, read-only report generator over :class:AssemblyEvent records.
Produces the v1 report family: injection precision (@budget and @rank),
confidence calibration (acted-rate by injection-time score), and decay
regret (injected-then-dismissed vs -then-acted by score bucket). All
metrics are computed only over injected memories that carry a joined
outcome; injections without an outcome are counted separately as
pending. Fusion-disagreement and refusal-threshold analyses are
explicit fast-follows (the trace already stores the fused score, so they
need no schema change).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
event_model
|
The event Model class. Default :class: |
AssemblyEvent
|
|
agent_id
|
Optional partition to restrict analysis to one agent. |
None
|
Source code in src/popoto/recipes/memory_telemetry.py
449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 | |
load_events(since=None, limit=None)
¶
Load events, optionally filtered by agent_id and ts >= since.
Read-only. since is a unix timestamp (float); filtering is done in
Python over the TTL-bounded corpus. limit caps the most-recent N by
timestamp.
Source code in src/popoto/recipes/memory_telemetry.py
injection_precision(since=None, limit=None)
¶
Fraction of injected-and-labeled memories that were acted on.
Returns {injected, labeled, pending, acted, acted_rate,
acted_or_used_rate, by_rank} where by_rank maps rank -> that
rank's acted_rate over labeled injections.
Source code in src/popoto/recipes/memory_telemetry.py
confidence_calibration(since=None, limit=None)
¶
Acted-rate bucketed by injection-time score.
Returns {buckets: [{label, labeled, acted, acted_rate}, ...]} over
the 5 :data:CALIBRATION_BUCKET_EDGES buckets. A well-calibrated
scorer shows acted_rate rising monotonically across buckets.
Source code in src/popoto/recipes/memory_telemetry.py
decay_regret(since=None, limit=None)
¶
Injected-then-regretted vs injected-then-acted, by score bucket.
"Regret" = injected but the outcome was dismissed or
contradicted (surfaced but wrong). Contrasted with acted. This
is the first empirical feedback signal for the decay magic numbers.
Returns {regret, acted, regret_rate, by_bucket: [...]}.
Source code in src/popoto/recipes/memory_telemetry.py
report(since=None, limit=None)
¶
Render the v1 telemetry report as markdown.
Style mirrors the sweep reports: a headline table plus per-analysis breakdowns. Read-only; safe to run against a live store.
Source code in src/popoto/recipes/memory_telemetry.py
report_outcomes(event_id, outcome_map, *, event_model=AssemblyEvent, apply_effects=False, instances=None)
¶
Join later outcomes onto the matching AssemblyEvent.
For each (memory_key -> outcome) in outcome_map whose key was
injected in this event, appends {key, outcome, at} to the event's
outcomes list and saves. Record-only by default: telemetry is pure
observation and does not double-apply confidence/decay effects (the agent
stack applies those via ObservationProtocol.on_context_used separately).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
event_id
|
The |
required | |
outcome_map
|
Dict mapping memory redis keys (the same keys stored in
|
required | |
event_model
|
The event Model class. Default :class: |
AssemblyEvent
|
|
apply_effects
|
bool
|
When True (and |
False
|
instances
|
Optional list of the memory instances, required only when
|
None
|
Returns:
| Type | Description |
|---|---|
|
The updated |
|
|
(e.g. its TTL expired) — a missing event is a logged no-op, not an |
|
|
error. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If any outcome string is not a valid outcome. |
Note
Re-saving refreshes the event's TTL to the model default. A same-week outcome join therefore keeps the event alive another full TTL — fine, the event is still the freshest evidence for that injection.
Source code in src/popoto/recipes/memory_telemetry.py
340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 | |