1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
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
//! `vipune promote` — candidate → active promotion.
//!
//! Implements sub-issue 4 of issue #194: a **pure evaluator** that decides
//! whether a memory should be promoted from `candidate` to `active`, plus an
//! **apply path** that runs the evaluator over a project's candidates and
//! promotes the eligible ones through the existing status-update path.
//!
//! Eligibility (all must hold, mirroring the epic's "pure evaluator plus
//! apply path" criterion):
//! - `status == candidate` — the promotion precondition; `superseded` and
//! `deprecated` rows are NEVER promoted, and `active` rows are a no-op.
//! - `retrieval_count >= threshold` — **strict `>=`** boundary: a candidate
//! with `retrieval_count` one below the threshold (4 with the default 5)
//! is NOT promoted, a candidate at the threshold (5) IS. The threshold
//! defaults to 5 and is overridable via the `VIPUNE_PROMOTION_THRESHOLD`
//! environment variable, mirroring the `VIPUNE_RECENCY_WEIGHT` pattern
//! used by the recency knob in `src/config`.
//!
//! The retrieval telemetry (`retrieval_count`) that drives the decision is
//! populated by `touch_memories` at the two sites that represent user
//! retrieval: `search` with an explicit `--status candidate` (or
//! `--include-candidates`) filter and direct `get` by id. `list
//! --include-candidates` is output-only and does NOT increment the counter,
//! so a candidate left at zero retrievals never promotes — that is correct,
//! not a bug.
//!
//! The apply path issues `UPDATE status='active'` per eligible row via
//! `Database::update` with `UpdateOptions { status: Some("active"), .. }` —
//! the same path `vipune update --status` uses — and never deletes or
//! re-embeds anything.
//! Contract (issue #255): the open failure keeps the vipune-owned variant it
//! was classified into — a lock stays [`Error::DatabaseLocked`], a corrupt
//! BLOB stays [`Error::CorruptEmbedding`] with its `id` — so a library or MCP
//! consumer can still tell the two apart. `commands::db_busy` pins this.
use crateError;
use crate;
use ExitCode;
/// Default promotion threshold: a candidate promotes when
/// `retrieval_count >= PROMOTION_THRESHOLD_DEFAULT` (i.e. 5).
pub const PROMOTION_THRESHOLD_DEFAULT: i64 = 5;
/// Environment variable that overrides the promotion threshold, mirroring the
/// `VIPUNE_RECENCY_WEIGHT` env-override pattern used by the recency knob.
pub const PROMOTION_THRESHOLD_ENV: &str = "VIPUNE_PROMOTION_THRESHOLD";
/// A single row the pure evaluator needs to make a promotion decision.
///
/// Kept minimal and free of `&str` borrows so the evaluator is a trivially
/// testable pure function that does not touch the database.
pub
/// Pure promotion evaluator.
///
/// Returns `true` only when the row is a `candidate` (the promotion
/// precondition) AND its `retrieval_count` meets or exceeds `threshold`.
///
/// This is a pure function — no database, no I/O, no time — so the strict
/// `>=` boundary and the status exclusions are each directly testable:
/// - a `candidate` with `retrieval_count` below `threshold` is NOT promoted;
/// - a `candidate` with `retrieval_count` exactly at `threshold` IS promoted;
/// - any `superseded`, `deprecated`, or `active` row is NOT promoted,
/// regardless of `retrieval_count`.
pub
/// Resolve the promotion threshold, honouring the `VIPUNE_PROMOTION_THRESHOLD`
/// environment override.
///
/// Mirrors `config::env_parser::apply_recency_weight_override`: if the
/// variable is set to a non-negative integer it wins, otherwise the default
/// threshold is returned. A malformed or negative value is an
/// [`Error::InvalidInput`] that names the offending value (the same shape the
/// recency parser uses for `VIPUNE_RECENCY_WEIGHT`).
pub
/// Collect the rows the evaluator needs for a project's candidates.
///
/// Fetches `(id, status, retrieval_count)` for every `candidate` row in the
/// project. Only candidates are fetched because only candidates can promote —
/// active/superseded/deprecated rows are structurally ineligible and skipping
/// them keeps the read cheap.
pub
/// Run the promotion apply path over a project's candidates.
///
/// Fetches the project's candidates, evaluates each via [`should_promote`],
/// and for every eligible row issues `UPDATE status='active'` through
/// `Database::update` (the existing status-update path, exactly what
/// `vipune update --status active` uses). Returns the number of rows
/// promoted.
///
/// # Errors
///
/// Returns an error if the threshold override is invalid, the candidate
/// fetch fails, or any status update fails (e.g. the row vanished between
/// fetch and update).
pub
/// Handle the `vipune promote` command.
///
/// # Arguments
///
/// * `db_path` - Path to the SQLite database (already resolved after the
/// `--db-path` override is applied).
/// * `project_id` - Project to scan for promotable candidates.
/// * `json` - If true, emit the JSON response; otherwise human-readable.
///
/// # Errors
///
/// Returns an error if the database cannot be opened, the threshold override
/// is invalid, or a status update fails.