superlocalmemory 4.1.6 → 4.1.7

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 (53) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/CHANGELOG.md +19 -0
  3. package/README.md +3 -3
  4. package/package.json +3 -1
  5. package/plugin/.claude-plugin/plugin.json +1 -1
  6. package/plugin/CLAUDE.md +3 -3
  7. package/plugin/agents/slm-governance-advisor.md +1 -1
  8. package/plugin/agents/slm-loop-runner.md +1 -1
  9. package/plugin/agents/slm-memory-advisor.md +1 -1
  10. package/plugin/agents/slm-optimize-advisor.md +1 -1
  11. package/plugin/requirements.txt +1 -1
  12. package/plugin/skills/slm-cache/SKILL.md +1 -1
  13. package/plugin/skills/slm-compress/SKILL.md +1 -1
  14. package/plugin/skills/slm-governance/SKILL.md +1 -1
  15. package/plugin/skills/slm-graph/SKILL.md +1 -1
  16. package/plugin/skills/slm-loop/SKILL.md +1 -1
  17. package/plugin/skills/slm-mesh/SKILL.md +1 -1
  18. package/plugin/skills/slm-profile/SKILL.md +1 -1
  19. package/plugin/skills/slm-recall/SKILL.md +1 -1
  20. package/plugin/skills/slm-remember/SKILL.md +1 -1
  21. package/plugin/skills/slm-scope/SKILL.md +1 -1
  22. package/plugin/skills/slm-session/SKILL.md +1 -1
  23. package/plugin/skills/slm-status/SKILL.md +1 -1
  24. package/plugin-src/agents/slm-memory-advisor.md +49 -0
  25. package/plugin-src/agents/slm-optimize-advisor.md +44 -0
  26. package/plugin-src/rules/AGENTS.md +1 -1
  27. package/plugin-src/skills/slm-cache/SKILL.md +1 -1
  28. package/plugin-src/skills/slm-compress/SKILL.md +1 -1
  29. package/plugin-src/skills/slm-governance/SKILL.md +1 -1
  30. package/plugin-src/skills/slm-graph/SKILL.md +1 -1
  31. package/plugin-src/skills/slm-loop/SKILL.md +1 -1
  32. package/plugin-src/skills/slm-mesh/SKILL.md +1 -1
  33. package/plugin-src/skills/slm-profile/SKILL.md +1 -1
  34. package/plugin-src/skills/slm-recall/SKILL.md +1 -1
  35. package/plugin-src/skills/slm-remember/SKILL.md +1 -1
  36. package/plugin-src/skills/slm-scope/SKILL.md +1 -1
  37. package/plugin-src/skills/slm-session/SKILL.md +1 -1
  38. package/plugin-src/skills/slm-status/SKILL.md +1 -1
  39. package/pyproject.toml +5 -1
  40. package/src/superlocalmemory/__init__.py +1 -1
  41. package/src/superlocalmemory/cli/host_upgrades.py +21 -7
  42. package/src/superlocalmemory/core/engine.py +7 -1
  43. package/src/superlocalmemory/core/recall_pipeline.py +13 -6
  44. package/src/superlocalmemory/core/session_identity.py +14 -1
  45. package/src/superlocalmemory/hooks/codex_assets.py +165 -45
  46. package/src/superlocalmemory/learning/bandit.py +22 -2
  47. package/src/superlocalmemory/learning/engagement_features.py +279 -0
  48. package/src/superlocalmemory/learning/outcome_queue.py +14 -0
  49. package/src/superlocalmemory/learning/propensity.py +131 -0
  50. package/src/superlocalmemory/learning/reward.py +42 -16
  51. package/src/superlocalmemory/learning/reward_model.py +144 -0
  52. package/src/superlocalmemory/learning/reward_proxy.py +148 -22
  53. package/src/superlocalmemory/server/routes/v3_api.py +4 -3
@@ -0,0 +1,131 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later - see LICENSE file
3
+
4
+ """Inverse-propensity weighting, so the bandit cannot confirm itself.
5
+
6
+ THE BIAS
7
+ --------
8
+ The bandit decides what is shown, and engagement is then measured on what was
9
+ shown. Feed that back raw and the loop is circular: an arm ranked first is seen
10
+ more, so it is engaged with more, so it is ranked first more. The arm that wins
11
+ is the one that was already winning, and the posterior records popularity it
12
+ manufactured rather than usefulness it discovered.
13
+
14
+ That is the self-referential signal in its exact form — a measurement taken
15
+ through the mechanism it is meant to evaluate cannot see that mechanism fail.
16
+
17
+ THE CORRECTION
18
+ --------------
19
+ Weight each observation by the inverse of the probability the policy had of
20
+ showing that arm. An arm the policy was unlikely to show, that was engaged with
21
+ anyway, is strong evidence; an arm the policy shows almost always is weak
22
+ evidence whatever happens to it. This is the standard IPS estimator from
23
+ counterfactual learning-to-rank, and it makes the update unbiased with respect
24
+ to the policy's own choices.
25
+
26
+ Under Thompson sampling the propensity is not a stored number: an arm is shown
27
+ when its posterior draw beats every competitor's, so the probability is
28
+ ``P(theta_i > theta_j for all j != i)`` with each ``theta ~ Beta(alpha, beta)``.
29
+ There is no closed form for more than two arms, so it is estimated by sampling.
30
+
31
+ WHEN THE COMPETITORS ARE UNKNOWN
32
+ --------------------------------
33
+ Return a weight of exactly 1.0 — no correction. A wrong correction is worse
34
+ than none: it would silently scale evidence by a number with no meaning, and
35
+ unlike an absent correction nothing downstream could tell. Abstaining from a
36
+ correction is visible in ``PropensityEstimate.corrected``.
37
+ """
38
+
39
+ from __future__ import annotations
40
+
41
+ import random
42
+ from dataclasses import dataclass
43
+
44
+ __all__ = ["PropensityEstimate", "estimate_propensity", "ips_weight", "MAX_WEIGHT"]
45
+
46
+ #: Ceiling on the weight a single observation may carry. IPS has unbounded
47
+ #: variance as propensity approaches zero: one rare event with p = 0.001 would
48
+ #: otherwise move a posterior by 1000 plays' worth. Clipping trades a little
49
+ #: bias for a variance that does not destroy the estimate — the standard
50
+ #: bias-variance trade in clipped IPS.
51
+ MAX_WEIGHT = 10.0
52
+
53
+ #: Propensities below this are treated as this value before inversion.
54
+ _MIN_PROPENSITY = 1.0 / MAX_WEIGHT
55
+
56
+ #: Monte Carlo draws. 2000 puts the standard error of a mid-range propensity
57
+ #: near 0.01, which is finer than the weight clipping can express.
58
+ _DRAWS = 2000
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class PropensityEstimate:
63
+ """A propensity and whether it was actually derived from anything."""
64
+
65
+ propensity: float
66
+ weight: float
67
+ corrected: bool
68
+ competitors: int = 0
69
+
70
+
71
+ def estimate_propensity(
72
+ arm: tuple[float, float],
73
+ competitors: list[tuple[float, float]],
74
+ *,
75
+ draws: int = _DRAWS,
76
+ rng: random.Random | None = None,
77
+ ) -> float:
78
+ """P(this arm's Thompson draw is the largest), by Monte Carlo.
79
+
80
+ ``arm`` and each competitor are ``(alpha, beta)`` posteriors. With no
81
+ competitors the arm is shown whenever it is considered, so the propensity
82
+ is 1.0 and the correction is a no-op.
83
+ """
84
+ if not competitors:
85
+ return 1.0
86
+ # Fixed seed by default: the same play settled twice must produce the
87
+ # same weight, or a retry would move a posterior differently than the
88
+ # first attempt did.
89
+ generator = rng or random.Random(20260824)
90
+ alpha, beta = _sane(arm)
91
+ others = [_sane(c) for c in competitors]
92
+
93
+ wins = 0
94
+ for _ in range(max(1, int(draws))):
95
+ mine = generator.betavariate(alpha, beta)
96
+ if all(mine > generator.betavariate(a, b) for a, b in others):
97
+ wins += 1
98
+ return wins / max(1, int(draws))
99
+
100
+
101
+ def _sane(posterior: tuple[float, float]) -> tuple[float, float]:
102
+ """Beta requires strictly positive parameters; a stored 0 would raise."""
103
+ alpha, beta = posterior
104
+ return (max(float(alpha), 1e-6), max(float(beta), 1e-6))
105
+
106
+
107
+ def ips_weight(
108
+ arm: tuple[float, float] | None,
109
+ competitors: list[tuple[float, float]] | None,
110
+ *,
111
+ draws: int = _DRAWS,
112
+ rng: random.Random | None = None,
113
+ ) -> PropensityEstimate:
114
+ """Clipped inverse-propensity weight for one observation.
115
+
116
+ Returns ``corrected=False`` and ``weight=1.0`` when there is nothing to
117
+ correct against, so a caller can tell an uncorrected update from a
118
+ corrected one that happened to land on 1.0.
119
+ """
120
+ if arm is None or not competitors:
121
+ return PropensityEstimate(propensity=1.0, weight=1.0, corrected=False)
122
+
123
+ propensity = estimate_propensity(arm, competitors, draws=draws, rng=rng)
124
+ clipped = max(propensity, _MIN_PROPENSITY)
125
+ weight = min(1.0 / clipped, MAX_WEIGHT)
126
+ return PropensityEstimate(
127
+ propensity=propensity,
128
+ weight=weight,
129
+ corrected=True,
130
+ competitors=len(competitors),
131
+ )
@@ -735,6 +735,20 @@ class EngagementRewardModel:
735
735
  signals = json.loads(row["signals_json"] or "{}")
736
736
  except json.JSONDecodeError: # pragma: no cover
737
737
  signals = {}
738
+ # A row that accumulated no signal has nothing to say. It used to
739
+ # be written out as an outcome anyway, carrying the label
740
+ # formula's base term of exactly 0.5 and marked ``settled`` as
741
+ # though a judgement had been reported. That value is not a
742
+ # harmless placeholder: ``alpha += 0.5`` with ``beta += 0.5`` moves
743
+ # both sides together, so the posterior keeps its mean and loses
744
+ # spread, and an arm settled this way repeatedly never leaves its
745
+ # prior.
746
+ #
747
+ # The row is still finalized, so it stops being rescanned; it is
748
+ # simply not turned into evidence it never was.
749
+ if not signals:
750
+ settle_ids.append(row["outcome_id"])
751
+ continue
738
752
  reward = _compute_label(signals)
739
753
  insert_batch.append(
740
754
  (
@@ -756,7 +770,11 @@ class EngagementRewardModel:
756
770
  _CHUNK = 500
757
771
  written = 0
758
772
  try:
759
- for i in range(0, len(insert_batch), _CHUNK):
773
+ # insert_batch and settle_ids are no longer parallel: a row with
774
+ # no signals is finalized without producing an outcome. Pad the
775
+ # insert side so each burst still pairs a write with the right
776
+ # finalizations, and never index one by the other's length.
777
+ for i in range(0, max(len(insert_batch), len(settle_ids)), _CHUNK):
760
778
  i_chunk = insert_batch[i:i + _CHUNK]
761
779
  s_chunk = settle_ids[i:i + _CHUNK]
762
780
  placeholders = ",".join("?" * len(s_chunk))
@@ -764,22 +782,30 @@ class EngagementRewardModel:
764
782
  conn = self._get_conn()
765
783
  conn.execute("BEGIN IMMEDIATE")
766
784
  try:
767
- conn.executemany(
768
- "INSERT OR REPLACE INTO action_outcomes "
769
- "(outcome_id, profile_id, query, fact_ids_json,"
770
- " outcome, context_json, timestamp, reward,"
771
- " settled, settled_at, recall_query_id) "
772
- "VALUES "
773
- "(?, ?, '', ?, 'settled', '{}', ?, ?, 1, ?, ?)",
774
- i_chunk,
775
- )
776
- conn.execute(
777
- "UPDATE pending_outcomes "
778
- f"SET status = 'settled' WHERE outcome_id IN ({placeholders})",
779
- s_chunk,
780
- )
785
+ if i_chunk:
786
+ conn.executemany(
787
+ "INSERT OR REPLACE INTO action_outcomes "
788
+ "(outcome_id, profile_id, query, fact_ids_json,"
789
+ " outcome, context_json, timestamp, reward,"
790
+ " settled, settled_at, recall_query_id) "
791
+ "VALUES "
792
+ "(?, ?, '', ?, 'settled', '{}', ?, ?, 1, ?, ?)",
793
+ i_chunk,
794
+ )
795
+ if s_chunk:
796
+ conn.execute(
797
+ "UPDATE pending_outcomes "
798
+ f"SET status = 'settled' "
799
+ f"WHERE outcome_id IN ({placeholders})",
800
+ s_chunk,
801
+ )
781
802
  conn.execute("COMMIT")
782
- written += len(i_chunk)
803
+ # Count what was FINALIZED, not what was written. A row
804
+ # with no signals is finalized without producing an
805
+ # outcome, and callers use this number to know the
806
+ # backlog drained — counting inserts would report 0
807
+ # forever and make the reaper look dead.
808
+ written += len(s_chunk)
783
809
  except sqlite3.Error:
784
810
  conn.execute("ROLLBACK")
785
811
  raise
@@ -0,0 +1,144 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later - see LICENSE file
3
+
4
+ """Turn observed engagement into a reward, or into an honest refusal.
5
+
6
+ ABSTENTION IS THE POINT
7
+ -----------------------
8
+ The ladder this replaces always produced a number. When it saw nothing it
9
+ produced ``0.5``, and ``0.5`` is not neutral: ``alpha += 0.5`` with
10
+ ``beta += 0.5`` holds a Beta posterior's mean at exactly 0.5 while shrinking
11
+ its variance, so each empty settlement makes an arm *more certain* it is
12
+ average and *less* movable by the evidence that finally arrives. Applied often
13
+ enough it does not merely fail to learn — it commits, with growing confidence,
14
+ to knowing nothing.
15
+
16
+ So this module returns ``None`` when nothing was observed. An unobserved recall
17
+ leaves the posterior untouched and free. Absence of evidence is recorded as
18
+ absence of evidence.
19
+
20
+ THE SCALE
21
+ ---------
22
+ Positive evidence maps into ``(0.5, 1.0]``, a requery to ``0.0``, and nothing to
23
+ ``None``. Nothing maps *to* 0.5, because that value is reserved for "no
24
+ information" and no observation carries that meaning: if it was worth
25
+ observing it was worth moving the posterior.
26
+
27
+ Weights are module constants and deliberately legible rather than fitted. There
28
+ is no ground-truth corpus of "was this memory actually useful" to fit against,
29
+ and inventing one from the system's own rankings would be the circularity
30
+ ``propensity.py`` exists to break. They are a documented prior over evidence
31
+ strength; the *learning* happens in the posterior these rewards update, not in
32
+ the constants.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ from dataclasses import dataclass
38
+
39
+ from superlocalmemory.learning.engagement_features import EngagementFeatures
40
+
41
+ __all__ = ["RewardDecision", "score", "REQUERY_REWARD"]
42
+
43
+ #: A question asked again is the one unambiguous statement that an answer did
44
+ #: not serve. It is the only hard zero available.
45
+ REQUERY_REWARD = 0.0
46
+
47
+ #: Evidence weights, strongest first.
48
+ #: - a memory whose content reaches a written artifact was used, not just read
49
+ #: - a follow-up memory overlapping it means the agent built on it
50
+ #: - appearing in any later action is real but weaker: the agent may have been
51
+ #: working on the subject regardless of what was recalled
52
+ _W_ARTIFACT = 0.50
53
+ _W_FOLLOWUP = 0.30
54
+ _W_PRESENCE = 0.20
55
+
56
+ #: Positive evidence starts just above the reserved no-information value, so
57
+ #: the weakest real observation still moves an arm up rather than nowhere.
58
+ _POSITIVE_FLOOR = 0.55
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class RewardDecision:
63
+ """A reward, or an abstention, with the reason attached.
64
+
65
+ ``reward is None`` means do not update. ``kind`` names what was seen so a
66
+ settled play can be explained after the fact instead of being a bare float.
67
+ """
68
+
69
+ reward: float | None
70
+ kind: str
71
+ detail: str = ""
72
+
73
+ @property
74
+ def abstained(self) -> bool:
75
+ return self.reward is None
76
+
77
+
78
+ def score(features: EngagementFeatures) -> RewardDecision:
79
+ """Map observations to a reward in [0, 1], or abstain.
80
+
81
+ Never raises: a settler running over a month of rows must not stop on one
82
+ malformed observation.
83
+ """
84
+ if features.marker_hit:
85
+ # The agent named the memory outright. Nothing makes it do this, so it
86
+ # is rare, but when it happens there is nothing to infer.
87
+ return RewardDecision(
88
+ reward=1.0,
89
+ kind="proxy_position",
90
+ detail="a recalled fact id appeared in a later tool event",
91
+ )
92
+
93
+ if features.requeried:
94
+ return RewardDecision(
95
+ reward=REQUERY_REWARD,
96
+ kind="proxy_requery",
97
+ detail="the same question was asked again inside the window",
98
+ )
99
+
100
+ if not features.observed:
101
+ return RewardDecision(
102
+ reward=None,
103
+ kind="unobserved",
104
+ detail=(
105
+ f"no engagement visible in {features.action_count} following "
106
+ "action(s); posterior left untouched"
107
+ ),
108
+ )
109
+
110
+ artifact = _clamp(features.artifact_overlap)
111
+ followup = _clamp(features.followup_write_overlap)
112
+ presence = _clamp(features.peak_overlap)
113
+
114
+ strength = (
115
+ _W_ARTIFACT * artifact
116
+ + _W_FOLLOWUP * followup
117
+ + _W_PRESENCE * presence
118
+ )
119
+ # strength in [0, 1]; map onto (floor, 1.0].
120
+ reward = _POSITIVE_FLOOR + (1.0 - _POSITIVE_FLOOR) * _clamp(strength)
121
+
122
+ if artifact > 0.0:
123
+ kind = "artifact_overlap"
124
+ elif followup > 0.0:
125
+ kind = "followup_overlap"
126
+ else:
127
+ kind = "presence_overlap"
128
+
129
+ return RewardDecision(
130
+ reward=round(reward, 6),
131
+ kind=kind,
132
+ detail=(
133
+ f"artifact={artifact:.3f} followup={followup:.3f} "
134
+ f"presence={presence:.3f} on {len(features.matched_fact_ids)} fact(s)"
135
+ ),
136
+ )
137
+
138
+
139
+ def _clamp(value: float) -> float:
140
+ try:
141
+ v = float(value)
142
+ except (TypeError, ValueError):
143
+ return 0.0
144
+ return 0.0 if v < 0.0 else (1.0 if v > 1.0 else v)
@@ -34,6 +34,11 @@ from datetime import datetime, timedelta, timezone
34
34
  from pathlib import Path
35
35
  from typing import Any
36
36
 
37
+ from superlocalmemory.learning.engagement_features import (
38
+ EngagementFeatures,
39
+ extract_features,
40
+ )
41
+ from superlocalmemory.learning.reward_model import score
37
42
  from superlocalmemory.core.topic_signature import compute_topic_signature
38
43
  from superlocalmemory.learning.bandit import ContextualBandit
39
44
 
@@ -105,10 +110,10 @@ def _shown_fact_ids(
105
110
  """The fact_ids this play recorded at recall time (M044), or [].
106
111
 
107
112
  Preferred over the ``learning_signals`` lookup below because it does not
108
- depend on the exposure enqueue, which is off: twenty rows per query, and
109
- the source of a 2,675x inflation in the ranking-phase counter. With no
110
- signals rows there was nothing to look for, so every play settled as the
111
- 120-second default and 165 arms sat at alpha == beta.
113
+ depend on the exposure enqueue, which is off because it wrote a row per
114
+ displayed memory per query and badly inflated the ranking-phase counter.
115
+ With no signals rows there is nothing to look for, so plays fell through to
116
+ the age-based default and arms stayed on their priors.
112
117
 
113
118
  Returns [] on a store where M044 has not run — "no such column" is an
114
119
  ``sqlite3.Error`` and is caught, so an unmigrated install falls back to the
@@ -366,6 +371,98 @@ def _default_deadline(
366
371
  )
367
372
 
368
373
 
374
+ #: Once a play is older than this with nothing observed, it is closed without
375
+ #: touching the posterior. Leaving it open forever would make every settler
376
+ #: pass rescan it; settling it at a neutral value is what this module stopped
377
+ #: doing. Closed-and-unjudged is the third option that was missing.
378
+ _ABSTAIN_EXPIRY_SEC = 900
379
+
380
+
381
+ def _expire_play(learning_conn: sqlite3.Connection, play_id: int) -> bool:
382
+ """Close a play without applying any reward.
383
+
384
+ The posterior is deliberately untouched: nothing was observed, so there is
385
+ nothing to learn, and a neutral update would shrink the arm's variance
386
+ around its prior and make it harder to move once evidence does arrive.
387
+ """
388
+ try:
389
+ learning_conn.execute(
390
+ "UPDATE bandit_plays SET settled_at = ?, settlement_type = ? "
391
+ "WHERE play_id = ? AND settled_at IS NULL",
392
+ (datetime.now(timezone.utc).isoformat(), "unobserved", int(play_id)),
393
+ )
394
+ learning_conn.commit()
395
+ return True
396
+ except sqlite3.Error as exc:
397
+ logger.debug("reward_proxy: expire play %s: %s", play_id, exc)
398
+ return False
399
+
400
+
401
+ def _session_for_play(
402
+ memory_conn: sqlite3.Connection | None,
403
+ query_id: str,
404
+ played_at: datetime,
405
+ profile_id: str,
406
+ ) -> str:
407
+ """The conversation this play happened in, or "" when it cannot be named.
408
+
409
+ The play and its outcome ticket are minted with different uuids on the same
410
+ recall, so the query_id is tried first and a time-and-profile match is the
411
+ fallback. An empty answer means no observation is possible, which the
412
+ caller turns into an abstention rather than a guess.
413
+ """
414
+ if memory_conn is None:
415
+ return ""
416
+ try:
417
+ row = memory_conn.execute(
418
+ "SELECT session_id FROM pending_outcomes WHERE recall_query_id = ? "
419
+ "LIMIT 1", (str(query_id),),
420
+ ).fetchone()
421
+ if row and row[0]:
422
+ return str(row[0])
423
+ window_ms = 5000
424
+ centre = int(played_at.timestamp() * 1000)
425
+ row = memory_conn.execute(
426
+ "SELECT session_id FROM pending_outcomes "
427
+ "WHERE profile_id = ? AND created_at_ms BETWEEN ? AND ? "
428
+ "ORDER BY ABS(created_at_ms - ?) LIMIT 1",
429
+ (str(profile_id), centre - window_ms, centre + window_ms, centre),
430
+ ).fetchone()
431
+ return str(row[0]) if row and row[0] else ""
432
+ except sqlite3.Error:
433
+ return ""
434
+
435
+
436
+ def _ips_for_play(
437
+ learning_conn: sqlite3.Connection, play_id: int, stratum: str, profile_id: str,
438
+ ):
439
+ """Inverse-propensity weight for this play against its stratum's arms."""
440
+ from superlocalmemory.learning.propensity import ips_weight
441
+
442
+ try:
443
+ row = learning_conn.execute(
444
+ "SELECT arm_id FROM bandit_plays WHERE play_id = ?", (int(play_id),),
445
+ ).fetchone()
446
+ if not row or not row[0]:
447
+ return ips_weight(None, None)
448
+ arm_id = str(row[0])
449
+ rows = learning_conn.execute(
450
+ "SELECT arm_id, alpha, beta FROM bandit_arms "
451
+ "WHERE profile_id = ? AND stratum = ?",
452
+ (str(profile_id), str(stratum or "")),
453
+ ).fetchall()
454
+ except sqlite3.Error:
455
+ return ips_weight(None, None)
456
+
457
+ mine, others = None, []
458
+ for candidate, alpha, beta in rows:
459
+ if str(candidate) == arm_id:
460
+ mine = (float(alpha), float(beta))
461
+ else:
462
+ others.append((float(alpha), float(beta)))
463
+ return ips_weight(mine, others)
464
+
465
+
369
466
  def settle_stale_plays(
370
467
  profile_id: str,
371
468
  db_path: Path | str,
@@ -406,27 +503,56 @@ def settle_stale_plays(
406
503
  _shown_fact_ids(learning_conn, row["play_id"])
407
504
  or _top3_fact_ids(learning_conn, row["query_id"])
408
505
  )
409
- reward: float | None = None
410
- kind = "default"
411
- if memory_conn is not None and _tool_event_hit(
412
- memory_conn, played, top3, profile_id=str(profile_id),
413
- ):
414
- reward = 1.0
415
- kind = "proxy_position"
416
- elif memory_conn is not None and _requery_detected(
417
- memory_conn, played, row["query_id"], profile_id=str(profile_id),
418
- ):
419
- reward = 0.0
420
- kind = "proxy_requery"
421
- elif age > _default_deadline(learning_conn, row):
422
- # P1: uncertain default once the window closes.
423
- reward = 0.5
424
- kind = "default"
506
+ # The ladder this replaces asked one question — did a recalled
507
+ # fact_id appear verbatim in a later tool event — and answered 0.5
508
+ # whenever it could not tell. Both halves failed: nothing makes an
509
+ # agent echo the marker, and 0.5 is not an absence of judgement but
510
+ # a confident one, tightening the posterior around its prior.
511
+ requeried = bool(
512
+ memory_conn is not None and _requery_detected(
513
+ memory_conn, played, row["query_id"],
514
+ profile_id=str(profile_id),
515
+ )
516
+ )
517
+ marker_hit = bool(
518
+ memory_conn is not None and _tool_event_hit(
519
+ memory_conn, played, top3, profile_id=str(profile_id),
520
+ )
521
+ )
522
+ session_id = _session_for_play(
523
+ memory_conn, str(row["query_id"] or ""), played, str(profile_id),
524
+ )
525
+ if memory_conn is not None and session_id:
526
+ features = extract_features(
527
+ memory_conn,
528
+ session_id=session_id,
529
+ profile_id=str(profile_id),
530
+ fact_ids=top3,
531
+ recalled_at=played,
532
+ requeried=requeried,
533
+ marker_hit=marker_hit,
534
+ )
425
535
  else:
426
- # Between 60 and 120 s with no evidence yet — wait.
536
+ features = EngagementFeatures(
537
+ requeried=requeried, marker_hit=marker_hit,
538
+ )
539
+
540
+ decision = score(features)
541
+ if decision.reward is None:
542
+ # Nothing observed. Wait while the window is still open, then
543
+ # close the play unjudged rather than inventing a number.
544
+ if age > _ABSTAIN_EXPIRY_SEC:
545
+ _expire_play(learning_conn, int(row["play_id"]))
427
546
  continue
428
547
 
429
- if bandit.update(int(row["play_id"]), reward, kind=kind):
548
+ estimate = _ips_for_play(
549
+ learning_conn, int(row["play_id"]),
550
+ str(row["stratum"] or ""), str(profile_id),
551
+ )
552
+ if bandit.update(
553
+ int(row["play_id"]), decision.reward,
554
+ kind=decision.kind, weight=estimate.weight,
555
+ ):
430
556
  settled += 1
431
557
  finally:
432
558
  try:
@@ -12,6 +12,7 @@ from pathlib import Path
12
12
  import os
13
13
  from fastapi import APIRouter, HTTPException, Request
14
14
  from fastapi.responses import JSONResponse
15
+ from superlocalmemory.core.session_identity import synthetic_session_id
15
16
  from superlocalmemory.core.config import CANONICAL_RECALL_LIMIT
16
17
  from superlocalmemory.core.status_contract import (
17
18
  COUNT_QUERIES,
@@ -36,15 +37,15 @@ def _signal_session_id() -> str:
36
37
 
37
38
  agent = str(get_current_agent_id() or "").strip()
38
39
  if agent:
39
- return f"agent:{agent}"
40
+ return synthetic_session_id("agent", agent)
40
41
  except Exception: # noqa: BLE001 -- naming the caller must never fail a read
41
42
  pass
42
43
  try:
43
44
  from superlocalmemory.server.routes.helpers import get_active_profile
44
45
 
45
- return f"api:{get_active_profile()}"
46
+ return synthetic_session_id("api", str(get_active_profile()))
46
47
  except Exception: # noqa: BLE001
47
- return "api:default"
48
+ return synthetic_session_id("api", "default")
48
49
 
49
50
 
50
51
  router = APIRouter(prefix="/api/v3", tags=["v3"])