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.
- package/.claude-plugin/marketplace.json +2 -2
- package/CHANGELOG.md +19 -0
- package/README.md +3 -3
- package/package.json +3 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/CLAUDE.md +3 -3
- package/plugin/agents/slm-governance-advisor.md +1 -1
- package/plugin/agents/slm-loop-runner.md +1 -1
- package/plugin/agents/slm-memory-advisor.md +1 -1
- package/plugin/agents/slm-optimize-advisor.md +1 -1
- package/plugin/requirements.txt +1 -1
- package/plugin/skills/slm-cache/SKILL.md +1 -1
- package/plugin/skills/slm-compress/SKILL.md +1 -1
- package/plugin/skills/slm-governance/SKILL.md +1 -1
- package/plugin/skills/slm-graph/SKILL.md +1 -1
- package/plugin/skills/slm-loop/SKILL.md +1 -1
- package/plugin/skills/slm-mesh/SKILL.md +1 -1
- package/plugin/skills/slm-profile/SKILL.md +1 -1
- package/plugin/skills/slm-recall/SKILL.md +1 -1
- package/plugin/skills/slm-remember/SKILL.md +1 -1
- package/plugin/skills/slm-scope/SKILL.md +1 -1
- package/plugin/skills/slm-session/SKILL.md +1 -1
- package/plugin/skills/slm-status/SKILL.md +1 -1
- package/plugin-src/agents/slm-memory-advisor.md +49 -0
- package/plugin-src/agents/slm-optimize-advisor.md +44 -0
- package/plugin-src/rules/AGENTS.md +1 -1
- package/plugin-src/skills/slm-cache/SKILL.md +1 -1
- package/plugin-src/skills/slm-compress/SKILL.md +1 -1
- package/plugin-src/skills/slm-governance/SKILL.md +1 -1
- package/plugin-src/skills/slm-graph/SKILL.md +1 -1
- package/plugin-src/skills/slm-loop/SKILL.md +1 -1
- package/plugin-src/skills/slm-mesh/SKILL.md +1 -1
- package/plugin-src/skills/slm-profile/SKILL.md +1 -1
- package/plugin-src/skills/slm-recall/SKILL.md +1 -1
- package/plugin-src/skills/slm-remember/SKILL.md +1 -1
- package/plugin-src/skills/slm-scope/SKILL.md +1 -1
- package/plugin-src/skills/slm-session/SKILL.md +1 -1
- package/plugin-src/skills/slm-status/SKILL.md +1 -1
- package/pyproject.toml +5 -1
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/cli/host_upgrades.py +21 -7
- package/src/superlocalmemory/core/engine.py +7 -1
- package/src/superlocalmemory/core/recall_pipeline.py +13 -6
- package/src/superlocalmemory/core/session_identity.py +14 -1
- package/src/superlocalmemory/hooks/codex_assets.py +165 -45
- package/src/superlocalmemory/learning/bandit.py +22 -2
- package/src/superlocalmemory/learning/engagement_features.py +279 -0
- package/src/superlocalmemory/learning/outcome_queue.py +14 -0
- package/src/superlocalmemory/learning/propensity.py +131 -0
- package/src/superlocalmemory/learning/reward.py +42 -16
- package/src/superlocalmemory/learning/reward_model.py +144 -0
- package/src/superlocalmemory/learning/reward_proxy.py +148 -22
- 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
|
-
|
|
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
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
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
|
|
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
|
|
109
|
-
|
|
110
|
-
signals rows there
|
|
111
|
-
|
|
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
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
46
|
+
return synthetic_session_id("api", str(get_active_profile()))
|
|
46
47
|
except Exception: # noqa: BLE001
|
|
47
|
-
return "api
|
|
48
|
+
return synthetic_session_id("api", "default")
|
|
48
49
|
|
|
49
50
|
|
|
50
51
|
router = APIRouter(prefix="/api/v3", tags=["v3"])
|