minima-cli 0.4.9__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 (161) hide show
  1. minima/__init__.py +5 -0
  2. minima/api/__init__.py +1 -0
  3. minima/api/auth.py +39 -0
  4. minima/api/errors.py +40 -0
  5. minima/api/routers/__init__.py +1 -0
  6. minima/api/routers/calibration.py +50 -0
  7. minima/api/routers/feedback.py +279 -0
  8. minima/api/routers/health.py +50 -0
  9. minima/api/routers/models.py +42 -0
  10. minima/api/routers/recommend.py +66 -0
  11. minima/api/routers/savings.py +55 -0
  12. minima/api/routers/strategies.py +33 -0
  13. minima/catalog/__init__.py +1 -0
  14. minima/catalog/data/capability_priors.json +210 -0
  15. minima/catalog/data/model_aliases.json +12 -0
  16. minima/catalog/merge.py +69 -0
  17. minima/catalog/refresh.py +54 -0
  18. minima/catalog/sources/__init__.py +1 -0
  19. minima/catalog/sources/litellm.py +19 -0
  20. minima/catalog/sources/openrouter.py +25 -0
  21. minima/catalog/store.py +86 -0
  22. minima/config.py +288 -0
  23. minima/deps.py +35 -0
  24. minima/llm/__init__.py +1 -0
  25. minima/llm/anthropic.py +106 -0
  26. minima/llm/base.py +196 -0
  27. minima/llm/gemini.py +124 -0
  28. minima/llm/registry.py +54 -0
  29. minima/logging.py +28 -0
  30. minima/main.py +109 -0
  31. minima/memory/__init__.py +1 -0
  32. minima/memory/adapter.py +572 -0
  33. minima/memory/keys.py +83 -0
  34. minima/memory/records.py +190 -0
  35. minima/memory/threadpool.py +41 -0
  36. minima/metrics/__init__.py +1 -0
  37. minima/metrics/calibration.py +415 -0
  38. minima/metrics/report.py +116 -0
  39. minima/metrics/savings.py +98 -0
  40. minima/recommender/__init__.py +1 -0
  41. minima/recommender/_pg_pool.py +38 -0
  42. minima/recommender/_redis_client.py +32 -0
  43. minima/recommender/aggregate.py +157 -0
  44. minima/recommender/classify.py +165 -0
  45. minima/recommender/decisionlog.py +505 -0
  46. minima/recommender/durablerefs.py +312 -0
  47. minima/recommender/engine.py +997 -0
  48. minima/recommender/escalation.py +83 -0
  49. minima/recommender/propensity.py +189 -0
  50. minima/recommender/recstore.py +368 -0
  51. minima/recommender/score.py +318 -0
  52. minima/recommender/types.py +166 -0
  53. minima/schemas/__init__.py +1 -0
  54. minima/schemas/common.py +73 -0
  55. minima/schemas/feedback.py +34 -0
  56. minima/schemas/models_catalog.py +36 -0
  57. minima/schemas/recommend.py +104 -0
  58. minima/schemas/savings.py +39 -0
  59. minima/schemas/strategies.py +57 -0
  60. minima/schemas/workflow.py +43 -0
  61. minima/seeding/__init__.py +1 -0
  62. minima/seeding/items.py +42 -0
  63. minima/seeding/llmrouterbench.py +232 -0
  64. minima/seeding/routerbench.py +141 -0
  65. minima/seeding/run_seed.py +56 -0
  66. minima/seeding/synthetic.py +70 -0
  67. minima/tenancy/__init__.py +8 -0
  68. minima/tenancy/context.py +37 -0
  69. minima/tenancy/passthrough.py +110 -0
  70. minima/version.py +3 -0
  71. minima_cli-0.4.9.dist-info/METADATA +275 -0
  72. minima_cli-0.4.9.dist-info/RECORD +161 -0
  73. minima_cli-0.4.9.dist-info/WHEEL +4 -0
  74. minima_cli-0.4.9.dist-info/entry_points.txt +5 -0
  75. minima_cli-0.4.9.dist-info/licenses/LICENSE +295 -0
  76. minima_client/__init__.py +19 -0
  77. minima_client/autocapture.py +101 -0
  78. minima_client/client.py +301 -0
  79. minima_client/errors.py +23 -0
  80. minima_harness/LICENSE_PI +32 -0
  81. minima_harness/__init__.py +16 -0
  82. minima_harness/agent/__init__.py +72 -0
  83. minima_harness/agent/agent.py +276 -0
  84. minima_harness/agent/events.py +124 -0
  85. minima_harness/agent/loop.py +311 -0
  86. minima_harness/agent/state.py +79 -0
  87. minima_harness/agent/tools.py +97 -0
  88. minima_harness/ai/__init__.py +66 -0
  89. minima_harness/ai/compat.py +71 -0
  90. minima_harness/ai/errors.py +96 -0
  91. minima_harness/ai/events.py +117 -0
  92. minima_harness/ai/openrouter_catalog.py +153 -0
  93. minima_harness/ai/provider_catalog.py +299 -0
  94. minima_harness/ai/provider_quirks.py +37 -0
  95. minima_harness/ai/providers/__init__.py +75 -0
  96. minima_harness/ai/providers/_common.py +48 -0
  97. minima_harness/ai/providers/anthropic.py +290 -0
  98. minima_harness/ai/providers/base.py +65 -0
  99. minima_harness/ai/providers/faux.py +173 -0
  100. minima_harness/ai/providers/google.py +221 -0
  101. minima_harness/ai/providers/openai_compat.py +278 -0
  102. minima_harness/ai/registry.py +184 -0
  103. minima_harness/ai/stream.py +82 -0
  104. minima_harness/ai/tools.py +51 -0
  105. minima_harness/ai/types.py +204 -0
  106. minima_harness/ai/usage.py +41 -0
  107. minima_harness/minima/__init__.py +40 -0
  108. minima_harness/minima/cache.py +102 -0
  109. minima_harness/minima/config.py +85 -0
  110. minima_harness/minima/goals.py +226 -0
  111. minima_harness/minima/judge.py +144 -0
  112. minima_harness/minima/mapping.py +147 -0
  113. minima_harness/minima/meter.py +143 -0
  114. minima_harness/minima/router.py +220 -0
  115. minima_harness/minima/runtime.py +544 -0
  116. minima_harness/minima/signals.py +195 -0
  117. minima_harness/session/__init__.py +14 -0
  118. minima_harness/session/format.py +35 -0
  119. minima_harness/session/store.py +236 -0
  120. minima_harness/tasks/__init__.py +17 -0
  121. minima_harness/tasks/task_set.py +78 -0
  122. minima_harness/tools/__init__.py +7 -0
  123. minima_harness/tools/_io.py +34 -0
  124. minima_harness/tools/bash.py +70 -0
  125. minima_harness/tools/builtin.py +23 -0
  126. minima_harness/tools/edit.py +50 -0
  127. minima_harness/tools/find.py +38 -0
  128. minima_harness/tools/grep.py +73 -0
  129. minima_harness/tools/ls.py +35 -0
  130. minima_harness/tools/read.py +38 -0
  131. minima_harness/tools/tasks.py +75 -0
  132. minima_harness/tools/write.py +36 -0
  133. minima_harness/tui/__init__.py +3 -0
  134. minima_harness/tui/analytics.py +111 -0
  135. minima_harness/tui/app.py +1927 -0
  136. minima_harness/tui/bridge.py +103 -0
  137. minima_harness/tui/cli.py +227 -0
  138. minima_harness/tui/clipboard.py +60 -0
  139. minima_harness/tui/commands.py +49 -0
  140. minima_harness/tui/compaction.py +17 -0
  141. minima_harness/tui/config_cli.py +141 -0
  142. minima_harness/tui/config_store.py +237 -0
  143. minima_harness/tui/context.py +93 -0
  144. minima_harness/tui/customize.py +95 -0
  145. minima_harness/tui/diff.py +53 -0
  146. minima_harness/tui/editor.py +43 -0
  147. minima_harness/tui/extensions.py +84 -0
  148. minima_harness/tui/extra_models.py +52 -0
  149. minima_harness/tui/history.py +71 -0
  150. minima_harness/tui/mubit.py +295 -0
  151. minima_harness/tui/overlays.py +593 -0
  152. minima_harness/tui/packages.py +59 -0
  153. minima_harness/tui/run_modes.py +66 -0
  154. minima_harness/tui/theme.py +77 -0
  155. minima_harness/tui/welcome.py +83 -0
  156. minima_harness/tui/widgets/__init__.py +3 -0
  157. minima_harness/tui/widgets/banner.py +38 -0
  158. minima_harness/tui/widgets/editor.py +83 -0
  159. minima_harness/tui/widgets/footer.py +73 -0
  160. minima_harness/tui/widgets/messages.py +151 -0
  161. minima_harness/tui/widgets/status.py +57 -0
@@ -0,0 +1,83 @@
1
+ """When to escalate a recommendation to the cheap-LLM reasoner.
2
+
3
+ Phase 1 computes the triggers but does not call a reasoner (provider defaults to
4
+ ``none``); the engine records the reasons as warnings and keeps the deterministic pick.
5
+ The reasoner itself is wired in a later phase.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+
12
+ from minima.config import Settings
13
+ from minima.recommender.aggregate import is_conflicted
14
+ from minima.recommender.types import CandidateScore, ModelAggregate
15
+
16
+
17
+ @dataclass(slots=True)
18
+ class EscalationDecision:
19
+ should_escalate: bool = False
20
+ reasons: list[str] = field(default_factory=list)
21
+
22
+
23
+ def evaluate(
24
+ *,
25
+ settings: Settings,
26
+ allow: bool,
27
+ total_weight: float,
28
+ distinct_models_with_evidence: int,
29
+ recommended_confidence: float,
30
+ ranked: list[CandidateScore],
31
+ aggregates: dict[str, ModelAggregate],
32
+ recommended_interval_width: float | None = None,
33
+ recommended_predicted_success: float = 0.0,
34
+ tau: float = 0.0,
35
+ ) -> EscalationDecision:
36
+ """Decide whether the cheap-LLM reasoner should be consulted.
37
+
38
+ Two modes. "legacy": four independent heuristics. "uncertainty": a single
39
+ posterior-interval-width gate on the recommended candidate replaces the
40
+ thin_evidence + low_confidence pair (the interval IS the principled "how little do
41
+ we know" statistic); conflict stays as a hard override and tie is kept because it
42
+ captures rank instability between candidates that the per-candidate interval
43
+ doesn't see. Every escalation is a paid reasoner call — fewer, better-targeted
44
+ triggers are the efficiency lever.
45
+ """
46
+ decision = EscalationDecision()
47
+ if not allow:
48
+ return decision
49
+
50
+ uncertainty_mode = settings.minima_escalation_mode.lower() == "uncertainty"
51
+ if uncertainty_mode and recommended_interval_width is not None:
52
+ if recommended_interval_width > settings.minima_escalation_interval_width:
53
+ decision.reasons.append("wide_interval")
54
+ else:
55
+ if (
56
+ total_weight < settings.minima_escalation_w_min
57
+ or distinct_models_with_evidence < settings.minima_escalation_n_min
58
+ ):
59
+ decision.reasons.append("thin_evidence")
60
+
61
+ if recommended_confidence < settings.minima_escalation_c_min:
62
+ decision.reasons.append("low_confidence")
63
+
64
+ near_delta = settings.minima_escalation_near_threshold_delta
65
+ if (
66
+ near_delta > 0
67
+ and tau > 0
68
+ and recommended_predicted_success > 0
69
+ and recommended_confidence > 0.2 # only when there's actual evidence, not a cold prior
70
+ and (recommended_predicted_success - tau) < near_delta
71
+ ):
72
+ decision.reasons.append("near_threshold")
73
+
74
+ if len(ranked) >= 2:
75
+ gap = ranked[0].score - ranked[1].score
76
+ if gap < settings.minima_escalation_tie_delta:
77
+ decision.reasons.append("tie")
78
+
79
+ if any(is_conflicted(agg) for agg in aggregates.values()):
80
+ decision.reasons.append("conflict")
81
+
82
+ decision.should_escalate = bool(decision.reasons)
83
+ return decision
@@ -0,0 +1,189 @@
1
+ """Logging-propensity tracker for inverse-propensity-weighting bias correction.
2
+
3
+ Minima only observes outcomes for models it recommended, so frequently-recommended
4
+ models accumulate more evidence than rarely-tried ones. IPW re-weights each model's
5
+ recalled evidence by 1/propensity (clipped) so under-explored models aren't unfairly
6
+ buried. An in-process tracker is the default; a SQLite-backed tracker persists the
7
+ counts so the bias correction survives restarts.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import sqlite3
13
+ from collections.abc import Iterable
14
+ from threading import Lock
15
+ from typing import Protocol, runtime_checkable
16
+
17
+ from minima.config import Settings
18
+
19
+
20
+ @runtime_checkable
21
+ class Propensity(Protocol):
22
+ def record(self, lane: str, cluster: str, model_id: str) -> None: ...
23
+
24
+ def propensities(self, lane: str, cluster: str, model_ids: Iterable[str]) -> dict[str, float]:
25
+ ...
26
+
27
+
28
+ def _laplace_shares(bucket: dict[str, int], ids: list[str]) -> dict[str, float]:
29
+ """Laplace-smoothed share of recommendations per model within a (lane, cluster)."""
30
+ m = len(ids) or 1
31
+ total = sum(bucket.get(mid, 0) for mid in ids)
32
+ denom = total + m
33
+ return {mid: (bucket.get(mid, 0) + 1) / denom for mid in ids}
34
+
35
+
36
+ class PropensityTracker:
37
+ """In-process propensity counts (lost on restart)."""
38
+
39
+ def __init__(self) -> None:
40
+ self._counts: dict[tuple[str, str, str], dict[str, int]] = {}
41
+ self._lock = Lock()
42
+
43
+ def record(self, lane: str, cluster: str, model_id: str, org_id: str = "default") -> None:
44
+ with self._lock:
45
+ bucket = self._counts.setdefault((org_id, lane, cluster), {})
46
+ bucket[model_id] = bucket.get(model_id, 0) + 1
47
+
48
+ def propensities(
49
+ self, lane: str, cluster: str, model_ids: Iterable[str], org_id: str = "default"
50
+ ) -> dict[str, float]:
51
+ ids = list(model_ids)
52
+ with self._lock:
53
+ bucket = dict(self._counts.get((org_id, lane, cluster), {}))
54
+ return _laplace_shares(bucket, ids)
55
+
56
+
57
+ class SqlitePropensityTracker:
58
+ """Durable propensity counts backed by SQLite (stdlib, no extra dependency)."""
59
+
60
+ def __init__(self, path: str):
61
+ self._conn = sqlite3.connect(path, check_same_thread=False)
62
+ self._lock = Lock()
63
+ with self._conn:
64
+ self._conn.execute(
65
+ """
66
+ CREATE TABLE IF NOT EXISTS propensity (
67
+ org_id TEXT NOT NULL DEFAULT 'default',
68
+ lane TEXT NOT NULL,
69
+ cluster TEXT NOT NULL,
70
+ model_id TEXT NOT NULL,
71
+ count INTEGER NOT NULL DEFAULT 0
72
+ )
73
+ """
74
+ )
75
+ # Add org_id to a pre-existing (dev) DB that predates multi-tenancy.
76
+ cols = {row[1] for row in self._conn.execute("PRAGMA table_info(propensity)")}
77
+ if "org_id" not in cols:
78
+ self._conn.execute(
79
+ "ALTER TABLE propensity ADD COLUMN org_id TEXT NOT NULL DEFAULT 'default'"
80
+ )
81
+ # Upsert target — works regardless of the original PRIMARY KEY shape.
82
+ self._conn.execute(
83
+ "CREATE UNIQUE INDEX IF NOT EXISTS ux_propensity "
84
+ "ON propensity(org_id, lane, cluster, model_id)"
85
+ )
86
+
87
+ def record(self, lane: str, cluster: str, model_id: str, org_id: str = "default") -> None:
88
+ with self._lock, self._conn:
89
+ self._conn.execute(
90
+ """
91
+ INSERT INTO propensity (org_id, lane, cluster, model_id, count)
92
+ VALUES (?, ?, ?, ?, 1)
93
+ ON CONFLICT(org_id, lane, cluster, model_id) DO UPDATE SET count = count + 1
94
+ """,
95
+ (org_id, lane, cluster, model_id),
96
+ )
97
+
98
+ def propensities(
99
+ self, lane: str, cluster: str, model_ids: Iterable[str], org_id: str = "default"
100
+ ) -> dict[str, float]:
101
+ ids = list(model_ids)
102
+ with self._lock:
103
+ rows = self._conn.execute(
104
+ "SELECT model_id, count FROM propensity "
105
+ "WHERE org_id = ? AND lane = ? AND cluster = ?",
106
+ (org_id, lane, cluster),
107
+ ).fetchall()
108
+ bucket = {str(mid): int(cnt) for mid, cnt in rows}
109
+ return _laplace_shares(bucket, ids)
110
+
111
+ def close(self) -> None:
112
+ with self._lock:
113
+ self._conn.close()
114
+
115
+
116
+ class OrgScopedPropensity:
117
+ """Binds a shared propensity backend to one org, presenting the ``Propensity`` Protocol."""
118
+
119
+ def __init__(self, backend: Propensity, org_id: str):
120
+ self._backend = backend
121
+ self._org_id = org_id
122
+
123
+ def record(self, lane: str, cluster: str, model_id: str) -> None:
124
+ self._backend.record(lane, cluster, model_id, self._org_id) # type: ignore[call-arg]
125
+
126
+ def propensities(self, lane: str, cluster: str, model_ids: Iterable[str]) -> dict[str, float]:
127
+ return self._backend.propensities(lane, cluster, model_ids, self._org_id) # type: ignore[call-arg]
128
+
129
+
130
+ class PostgresPropensityTracker:
131
+ """Durable propensity counts backed by PostgreSQL."""
132
+
133
+ def __init__(self, database_url: str):
134
+ from minima.recommender._pg_pool import cursor as _cursor
135
+
136
+ self._url = database_url
137
+ self._cursor = _cursor
138
+ with self._cursor(self._url) as cur:
139
+ cur.execute(
140
+ """
141
+ CREATE TABLE IF NOT EXISTS propensity (
142
+ org_id TEXT NOT NULL DEFAULT 'default',
143
+ lane TEXT NOT NULL,
144
+ cluster TEXT NOT NULL,
145
+ model_id TEXT NOT NULL,
146
+ count INTEGER NOT NULL DEFAULT 0,
147
+ UNIQUE(org_id, lane, cluster, model_id)
148
+ )
149
+ """
150
+ )
151
+
152
+ def record(self, lane: str, cluster: str, model_id: str, org_id: str = "default") -> None:
153
+ with self._cursor(self._url) as cur:
154
+ cur.execute(
155
+ """
156
+ INSERT INTO propensity (org_id, lane, cluster, model_id, count)
157
+ VALUES (%s, %s, %s, %s, 1)
158
+ ON CONFLICT (org_id, lane, cluster, model_id) DO UPDATE
159
+ SET count = propensity.count + 1
160
+ """,
161
+ (org_id, lane, cluster, model_id),
162
+ )
163
+
164
+ def propensities(
165
+ self, lane: str, cluster: str, model_ids: Iterable[str], org_id: str = "default"
166
+ ) -> dict[str, float]:
167
+ ids = list(model_ids)
168
+ with self._cursor(self._url) as cur:
169
+ cur.execute(
170
+ "SELECT model_id, count FROM propensity"
171
+ " WHERE org_id = %s AND lane = %s AND cluster = %s",
172
+ (org_id, lane, cluster),
173
+ )
174
+ rows = cur.fetchall()
175
+ bucket = {str(mid): int(cnt) for mid, cnt in rows}
176
+ return _laplace_shares(bucket, ids)
177
+
178
+
179
+ def build_propensity(settings: Settings) -> Propensity:
180
+ backend = settings.minima_recommendation_store.strip().lower()
181
+ if backend in ("cloudsql", "postgres", "postgresql"):
182
+ if not settings.minima_database_url:
183
+ raise RuntimeError(
184
+ "MINIMA_DATABASE_URL is required when MINIMA_RECOMMENDATION_STORE=cloudsql"
185
+ )
186
+ return PostgresPropensityTracker(settings.minima_database_url)
187
+ if backend == "sqlite":
188
+ return SqlitePropensityTracker(settings.minima_sqlite_path)
189
+ return PropensityTracker()
@@ -0,0 +1,368 @@
1
+ """Store mapping a recommendation_id to the evidence that produced it.
2
+
3
+ Feedback resolves a recommendation_id here to know exactly which Mubit entries to
4
+ credit, without the caller having to round-trip the neighbor ids. The default backend
5
+ is in-process with a TTL; a SQLite backend persists recommendations across restarts so
6
+ feedback that arrives after a redeploy still credits the right neighbors.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import sqlite3
13
+ import time
14
+ from dataclasses import dataclass, field
15
+ from threading import Lock
16
+ from typing import Protocol, runtime_checkable
17
+
18
+ from minima.config import Settings
19
+ from minima.logging import get_logger
20
+
21
+ log = get_logger("minima.recstore")
22
+
23
+
24
+ def _ensure_column(conn: sqlite3.Connection, table: str, column: str, decl: str) -> None:
25
+ """Add ``column`` to ``table`` if a pre-existing (dev) DB predates it."""
26
+ cols = {row[1] for row in conn.execute(f"PRAGMA table_info({table})")}
27
+ if column not in cols:
28
+ conn.execute(f"ALTER TABLE {table} ADD COLUMN {column} {decl}")
29
+
30
+
31
+ @dataclass(slots=True)
32
+ class StoredRecommendation:
33
+ recommendation_id: str
34
+ lane: str
35
+ user_id: str | None
36
+ task_type: str
37
+ difficulty: str
38
+ task_cluster: str
39
+ task_fingerprint: str
40
+ content: str
41
+ env_tags: list[str]
42
+ recommended_model_id: str
43
+ # model_id -> [(entry_id, reference_id), ...] recalled neighbors for that model
44
+ neighbors_by_model: dict[str, list[tuple[str, str | None]]] = field(default_factory=dict)
45
+ created_at: float = 0.0
46
+ # Owning org (multi-tenancy). "default" in single-tenant mode. A get scoped to a
47
+ # different org must NOT resolve this record — that is the cross-tenant guard on
48
+ # /v1/feedback (org A can't credit/poison org B's recommendation_id).
49
+ org_id: str = "default"
50
+
51
+
52
+ @runtime_checkable
53
+ class RecStore(Protocol):
54
+ def put(self, rec: StoredRecommendation) -> None: ...
55
+
56
+ def get(self, recommendation_id: str) -> StoredRecommendation | None: ...
57
+
58
+
59
+ class RecommendationStore:
60
+ def __init__(self, ttl_seconds: int = 86_400):
61
+ self._ttl = ttl_seconds
62
+ self._data: dict[str, StoredRecommendation] = {}
63
+ self._lock = Lock()
64
+
65
+ def put(self, rec: StoredRecommendation) -> None:
66
+ if rec.created_at == 0.0:
67
+ rec.created_at = time.monotonic()
68
+ with self._lock:
69
+ self._purge_locked()
70
+ self._data[rec.recommendation_id] = rec
71
+
72
+ def get(self, recommendation_id: str, org_id: str | None = None) -> StoredRecommendation | None:
73
+ with self._lock:
74
+ rec = self._data.get(recommendation_id)
75
+ if rec is None:
76
+ return None
77
+ if time.monotonic() - rec.created_at > self._ttl:
78
+ self._data.pop(recommendation_id, None)
79
+ return None
80
+ if org_id is not None and rec.org_id != org_id:
81
+ return None
82
+ return rec
83
+
84
+ def _purge_locked(self) -> None:
85
+ now = time.monotonic()
86
+ expired = [k for k, v in self._data.items() if now - v.created_at > self._ttl]
87
+ for k in expired:
88
+ self._data.pop(k, None)
89
+
90
+
91
+ def _serialize(rec: StoredRecommendation) -> str:
92
+ return json.dumps(
93
+ {
94
+ "lane": rec.lane,
95
+ "user_id": rec.user_id,
96
+ "task_type": rec.task_type,
97
+ "difficulty": rec.difficulty,
98
+ "task_cluster": rec.task_cluster,
99
+ "task_fingerprint": rec.task_fingerprint,
100
+ "content": rec.content,
101
+ "env_tags": rec.env_tags,
102
+ "recommended_model_id": rec.recommended_model_id,
103
+ "neighbors_by_model": {
104
+ mid: [[eid, ref] for (eid, ref) in pairs]
105
+ for mid, pairs in rec.neighbors_by_model.items()
106
+ },
107
+ }
108
+ )
109
+
110
+
111
+ def _deserialize(
112
+ rec_id: str, payload: str, created_at: float, org_id: str = "default"
113
+ ) -> StoredRecommendation:
114
+ d = json.loads(payload)
115
+ return StoredRecommendation(
116
+ recommendation_id=rec_id,
117
+ lane=d["lane"],
118
+ user_id=d.get("user_id"),
119
+ task_type=d["task_type"],
120
+ difficulty=d["difficulty"],
121
+ task_cluster=d["task_cluster"],
122
+ task_fingerprint=d["task_fingerprint"],
123
+ content=d["content"],
124
+ env_tags=list(d.get("env_tags") or []),
125
+ recommended_model_id=d["recommended_model_id"],
126
+ neighbors_by_model={
127
+ mid: [(p[0], p[1]) for p in pairs]
128
+ for mid, pairs in (d.get("neighbors_by_model") or {}).items()
129
+ },
130
+ created_at=created_at,
131
+ org_id=org_id,
132
+ )
133
+
134
+
135
+ class SqliteRecommendationStore:
136
+ """Durable recommendation store backed by SQLite (stdlib; wall-clock TTL)."""
137
+
138
+ def __init__(self, path: str, ttl_seconds: int = 86_400):
139
+ self._ttl = ttl_seconds
140
+ self._conn = sqlite3.connect(path, check_same_thread=False)
141
+ self._lock = Lock()
142
+ with self._conn:
143
+ self._conn.execute(
144
+ """
145
+ CREATE TABLE IF NOT EXISTS recommendations (
146
+ recommendation_id TEXT PRIMARY KEY,
147
+ created_at REAL NOT NULL,
148
+ payload TEXT NOT NULL,
149
+ org_id TEXT NOT NULL DEFAULT 'default'
150
+ )
151
+ """
152
+ )
153
+ _ensure_column(
154
+ self._conn, "recommendations", "org_id", "TEXT NOT NULL DEFAULT 'default'"
155
+ )
156
+
157
+ def put(self, rec: StoredRecommendation) -> None:
158
+ # Durable TTL uses wall-clock epoch (monotonic is meaningless across restarts).
159
+ created = time.time() if rec.created_at == 0.0 else rec.created_at
160
+ with self._lock, self._conn:
161
+ self._conn.execute(
162
+ "DELETE FROM recommendations WHERE created_at < ?", (time.time() - self._ttl,)
163
+ )
164
+ self._conn.execute(
165
+ "INSERT OR REPLACE INTO recommendations "
166
+ "(recommendation_id, created_at, payload, org_id) VALUES (?, ?, ?, ?)",
167
+ (rec.recommendation_id, created, _serialize(rec), rec.org_id),
168
+ )
169
+
170
+ def get(self, recommendation_id: str, org_id: str | None = None) -> StoredRecommendation | None:
171
+ with self._lock:
172
+ row = self._conn.execute(
173
+ "SELECT created_at, payload, org_id FROM recommendations "
174
+ "WHERE recommendation_id = ?",
175
+ (recommendation_id,),
176
+ ).fetchone()
177
+ if row is None:
178
+ return None
179
+ created_at, payload, row_org = float(row[0]), str(row[1]), str(row[2])
180
+ if time.time() - created_at > self._ttl:
181
+ with self._lock, self._conn:
182
+ self._conn.execute(
183
+ "DELETE FROM recommendations WHERE recommendation_id = ?", (recommendation_id,)
184
+ )
185
+ return None
186
+ if org_id is not None and row_org != org_id:
187
+ return None
188
+ return _deserialize(recommendation_id, payload, created_at, row_org)
189
+
190
+ def close(self) -> None:
191
+ with self._lock:
192
+ self._conn.close()
193
+
194
+
195
+ class OrgScopedRecStore:
196
+ """Binds a shared recstore backend to one org, presenting the ``RecStore`` Protocol.
197
+
198
+ ``put`` stamps the org onto the record; ``get`` resolves only records owned by this
199
+ org — so a feedback call authenticated as org A cannot resolve org B's
200
+ recommendation_id. Used in both modes (single-tenant binds org ``"default"``).
201
+ """
202
+
203
+ def __init__(self, backend: RecStore, org_id: str):
204
+ self._backend = backend
205
+ self._org_id = org_id
206
+
207
+ def put(self, rec: StoredRecommendation) -> None:
208
+ rec.org_id = self._org_id
209
+ self._backend.put(rec)
210
+
211
+ def get(self, recommendation_id: str) -> StoredRecommendation | None:
212
+ return self._backend.get(recommendation_id, self._org_id) # type: ignore[call-arg]
213
+
214
+
215
+ class PostgresRecommendationStore:
216
+ """Durable recommendation store backed by PostgreSQL (Cloud SQL via Auth Proxy)."""
217
+
218
+ def __init__(self, database_url: str, ttl_seconds: int = 86_400):
219
+ from minima.recommender._pg_pool import cursor as _cursor
220
+
221
+ self._ttl = ttl_seconds
222
+ self._url = database_url
223
+ self._cursor = _cursor
224
+ with self._cursor(self._url) as cur:
225
+ cur.execute(
226
+ """
227
+ CREATE TABLE IF NOT EXISTS recommendations (
228
+ recommendation_id TEXT PRIMARY KEY,
229
+ created_at DOUBLE PRECISION NOT NULL,
230
+ payload TEXT NOT NULL,
231
+ org_id TEXT NOT NULL DEFAULT 'default'
232
+ )
233
+ """
234
+ )
235
+ cur.execute(
236
+ "CREATE INDEX IF NOT EXISTS ix_recs_created_at ON recommendations(created_at)"
237
+ )
238
+
239
+ def put(self, rec: StoredRecommendation) -> None:
240
+ import time
241
+
242
+ created = time.time() if rec.created_at == 0.0 else rec.created_at
243
+ with self._cursor(self._url) as cur:
244
+ cur.execute(
245
+ "DELETE FROM recommendations WHERE created_at < %s", (time.time() - self._ttl,)
246
+ )
247
+ cur.execute(
248
+ """
249
+ INSERT INTO recommendations (recommendation_id, created_at, payload, org_id)
250
+ VALUES (%s, %s, %s, %s)
251
+ ON CONFLICT (recommendation_id) DO UPDATE SET
252
+ created_at = EXCLUDED.created_at,
253
+ payload = EXCLUDED.payload,
254
+ org_id = EXCLUDED.org_id
255
+ """,
256
+ (rec.recommendation_id, created, _serialize(rec), rec.org_id),
257
+ )
258
+
259
+ def get(self, recommendation_id: str, org_id: str | None = None) -> StoredRecommendation | None:
260
+ import time
261
+
262
+ with self._cursor(self._url) as cur:
263
+ cur.execute(
264
+ "SELECT created_at, payload, org_id FROM recommendations"
265
+ " WHERE recommendation_id = %s",
266
+ (recommendation_id,),
267
+ )
268
+ row = cur.fetchone()
269
+ if row is None:
270
+ return None
271
+ created_at, payload, row_org = float(row[0]), str(row[1]), str(row[2])
272
+ if time.time() - created_at > self._ttl:
273
+ with self._cursor(self._url) as cur:
274
+ cur.execute(
275
+ "DELETE FROM recommendations WHERE recommendation_id = %s",
276
+ (recommendation_id,),
277
+ )
278
+ return None
279
+ if org_id is not None and row_org != org_id:
280
+ return None
281
+ return _deserialize(recommendation_id, payload, created_at, row_org)
282
+
283
+
284
+ class RedisRecommendationStore:
285
+ """Recommendation store backed by Redis (Cloud Memorystore).
286
+
287
+ Each recommendation is stored as a Redis hash at key ``rec:{recommendation_id}``
288
+ with a wall-clock TTL. Org isolation is enforced by checking the ``org_id`` field
289
+ on every get.
290
+ """
291
+
292
+ def __init__(self, redis_url: str, ttl_seconds: int = 86_400):
293
+ from minima.recommender._redis_client import get_client
294
+
295
+ self._ttl = ttl_seconds
296
+ self._r = get_client(redis_url)
297
+
298
+ def _key(self, recommendation_id: str) -> str:
299
+ return f"rec:{recommendation_id}"
300
+
301
+ def put(self, rec: StoredRecommendation) -> None:
302
+ import time
303
+
304
+ created = time.time() if rec.created_at == 0.0 else rec.created_at
305
+ key = self._key(rec.recommendation_id)
306
+ self._r.hset(
307
+ key,
308
+ mapping={
309
+ "payload": _serialize(rec),
310
+ "created_at": str(created),
311
+ "org_id": rec.org_id,
312
+ },
313
+ )
314
+ self._r.expire(key, self._ttl)
315
+
316
+ def get(self, recommendation_id: str, org_id: str | None = None) -> StoredRecommendation | None:
317
+ import time
318
+
319
+ key = self._key(recommendation_id)
320
+ row = self._r.hgetall(key)
321
+ if not row:
322
+ return None
323
+ created_at = float(row["created_at"])
324
+ if time.time() - created_at > self._ttl:
325
+ self._r.delete(key)
326
+ return None
327
+ row_org = row.get("org_id", "default")
328
+ if org_id is not None and row_org != org_id:
329
+ return None
330
+ from minima.recommender._redis_client import decode
331
+
332
+ return _deserialize(recommendation_id, decode(row["payload"]), created_at, decode(row_org))
333
+
334
+
335
+ def build_recstore(settings: Settings) -> RecStore:
336
+ # MINIMA_RECSTORE_BACKEND overrides the backend for RecStore + DurableRefs only.
337
+ # Falls back to MINIMA_RECOMMENDATION_STORE when not set.
338
+ backend = (
339
+ settings.minima_recstore_backend.strip().lower()
340
+ or settings.minima_recommendation_store.strip().lower()
341
+ )
342
+ ttl = settings.minima_recommendation_ttl_seconds
343
+ if backend == "redis":
344
+ if not settings.minima_redis_url:
345
+ raise RuntimeError("MINIMA_REDIS_URL is required when MINIMA_RECSTORE_BACKEND=redis")
346
+ return RedisRecommendationStore(settings.minima_redis_url, ttl)
347
+ if backend in ("cloudsql", "postgres", "postgresql"):
348
+ if not settings.minima_database_url:
349
+ raise RuntimeError(
350
+ "MINIMA_DATABASE_URL is required when MINIMA_RECOMMENDATION_STORE=cloudsql"
351
+ )
352
+ return PostgresRecommendationStore(settings.minima_database_url, ttl)
353
+ if backend == "sqlite":
354
+ return SqliteRecommendationStore(settings.minima_sqlite_path, ttl)
355
+ return RecommendationStore(ttl)
356
+
357
+
358
+ class LaneCounter:
359
+ """Per-lane feedback counter used to trigger reflection on a cadence."""
360
+
361
+ def __init__(self) -> None:
362
+ self._counts: dict[str, int] = {}
363
+ self._lock = Lock()
364
+
365
+ def bump(self, lane: str) -> int:
366
+ with self._lock:
367
+ self._counts[lane] = self._counts.get(lane, 0) + 1
368
+ return self._counts[lane]