@dsh-enhanced/preference-learning 0.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/LICENSE +21 -0
- package/README.md +93 -0
- package/cordis.patch.yml +19 -0
- package/lib/catalog.d.ts +88 -0
- package/lib/catalog.d.ts.map +1 -0
- package/lib/catalog.js +124 -0
- package/lib/catalog.js.map +1 -0
- package/lib/index.d.ts +14 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +13 -0
- package/lib/index.js.map +1 -0
- package/lib/service.d.ts +67 -0
- package/lib/service.d.ts.map +1 -0
- package/lib/service.js +260 -0
- package/lib/service.js.map +1 -0
- package/lib/sqlite.d.ts +9 -0
- package/lib/sqlite.d.ts.map +1 -0
- package/lib/sqlite.js +214 -0
- package/lib/sqlite.js.map +1 -0
- package/lib/store.d.ts +47 -0
- package/lib/store.d.ts.map +1 -0
- package/lib/store.js +769 -0
- package/lib/store.js.map +1 -0
- package/lib/tools.d.ts +4 -0
- package/lib/tools.d.ts.map +1 -0
- package/lib/tools.js +125 -0
- package/lib/tools.js.map +1 -0
- package/lib/types.d.ts +103 -0
- package/lib/types.d.ts.map +1 -0
- package/lib/types.js +17 -0
- package/lib/types.js.map +1 -0
- package/lib/version.d.ts +2 -0
- package/lib/version.d.ts.map +1 -0
- package/lib/version.js +2 -0
- package/lib/version.js.map +1 -0
- package/package.json +97 -0
package/lib/store.js
ADDED
|
@@ -0,0 +1,769 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { isAbsolute, resolve } from 'node:path';
|
|
3
|
+
import { catalogSelection, preferenceCatalog, renderCatalogPreference, } from './catalog.js';
|
|
4
|
+
import { openPreferenceDatabase, preferenceSchemaVersion } from './sqlite.js';
|
|
5
|
+
import { preferenceActorTrustLevels, preferenceInterpretationTrustLevels, preferenceRollbackReasons, preferenceSignalSources, preferenceSignalStances, } from './types.js';
|
|
6
|
+
export class PreferenceStoreError extends Error {
|
|
7
|
+
code;
|
|
8
|
+
constructor(code, message) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.code = code;
|
|
11
|
+
this.name = 'PreferenceStoreError';
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
const actorWeights = Object.freeze({
|
|
15
|
+
'owner-authenticated': 400,
|
|
16
|
+
'delegated-authenticated': 250,
|
|
17
|
+
'system-attested': 150,
|
|
18
|
+
unverified: 25,
|
|
19
|
+
});
|
|
20
|
+
const interpretationWeights = Object.freeze({
|
|
21
|
+
'explicit-selection': 400,
|
|
22
|
+
'typed-feedback': 300,
|
|
23
|
+
'behavioral-inference': 100,
|
|
24
|
+
'model-inference': 40,
|
|
25
|
+
});
|
|
26
|
+
function boundedText(value, label, maxBytes) {
|
|
27
|
+
if (typeof value !== 'string')
|
|
28
|
+
throw new PreferenceStoreError('invalid-input', `${label} must be a string`);
|
|
29
|
+
const normalized = value.normalize('NFC').trim();
|
|
30
|
+
if (normalized === '' || Buffer.byteLength(normalized, 'utf8') > maxBytes) {
|
|
31
|
+
throw new PreferenceStoreError('invalid-input', `${label} must contain 1-${maxBytes} UTF-8 bytes`);
|
|
32
|
+
}
|
|
33
|
+
return normalized;
|
|
34
|
+
}
|
|
35
|
+
function enumValue(value, values, label) {
|
|
36
|
+
if (typeof value !== 'string' || !values.includes(value)) {
|
|
37
|
+
throw new PreferenceStoreError('invalid-input', `${label} is not an allowed value`);
|
|
38
|
+
}
|
|
39
|
+
return value;
|
|
40
|
+
}
|
|
41
|
+
function safeTimestamp(value, label) {
|
|
42
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
43
|
+
throw new PreferenceStoreError('invalid-input', `${label} must be a non-negative safe integer`);
|
|
44
|
+
}
|
|
45
|
+
return value;
|
|
46
|
+
}
|
|
47
|
+
export function canonicalPreferenceScope(input) {
|
|
48
|
+
const workspace = boundedText(input.workspace, 'scope.workspace', 4_096);
|
|
49
|
+
if (!isAbsolute(workspace))
|
|
50
|
+
throw new PreferenceStoreError('invalid-input', 'scope.workspace must be absolute');
|
|
51
|
+
const scope = Object.freeze({
|
|
52
|
+
workspace: resolve(workspace),
|
|
53
|
+
preset: boundedText(input.preset, 'scope.preset', 200),
|
|
54
|
+
});
|
|
55
|
+
const scopeKey = JSON.stringify([scope.workspace, scope.preset]);
|
|
56
|
+
return {
|
|
57
|
+
scope,
|
|
58
|
+
scopeKey,
|
|
59
|
+
scopeDigest: createHash('sha256').update(`preference-scope-v1\0${scopeKey}`).digest('hex'),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
function payloadHash(value) {
|
|
63
|
+
return createHash('sha256').update(JSON.stringify(value)).digest('hex');
|
|
64
|
+
}
|
|
65
|
+
function signalFromRow(row) {
|
|
66
|
+
return Object.freeze({
|
|
67
|
+
id: row.id,
|
|
68
|
+
scope: Object.freeze({ workspace: row.workspace, preset: row.preset }),
|
|
69
|
+
preferenceKey: row.preference_key,
|
|
70
|
+
candidateValue: row.candidate_value,
|
|
71
|
+
riskTier: row.risk_tier,
|
|
72
|
+
stance: row.stance,
|
|
73
|
+
actorTrust: row.actor_trust,
|
|
74
|
+
interpretationTrust: row.interpretation_trust,
|
|
75
|
+
source: row.source,
|
|
76
|
+
occurredAt: row.occurred_at,
|
|
77
|
+
idempotencyKey: row.idempotency_key,
|
|
78
|
+
recordedAt: row.recorded_at,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
function hypothesisFromRow(row) {
|
|
82
|
+
return Object.freeze({
|
|
83
|
+
id: row.id,
|
|
84
|
+
scope: Object.freeze({ workspace: row.workspace, preset: row.preset }),
|
|
85
|
+
preferenceKey: row.preference_key,
|
|
86
|
+
candidateValue: row.candidate_value,
|
|
87
|
+
riskTier: row.risk_tier,
|
|
88
|
+
claimState: row.claim_state,
|
|
89
|
+
effectState: row.effect_state,
|
|
90
|
+
confidenceBps: row.confidence_bps,
|
|
91
|
+
contradictionBps: row.contradiction_bps,
|
|
92
|
+
supportingSignals: row.supporting_signals,
|
|
93
|
+
contradictingSignals: row.contradicting_signals,
|
|
94
|
+
evidenceMass: row.evidence_mass,
|
|
95
|
+
expiresAt: row.expires_at,
|
|
96
|
+
activatedAt: row.activated_at ?? undefined,
|
|
97
|
+
rolledBackAt: row.rolled_back_at ?? undefined,
|
|
98
|
+
version: row.version,
|
|
99
|
+
createdAt: row.created_at,
|
|
100
|
+
updatedAt: row.updated_at,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
function hypothesisId(scopeKey, preferenceKey, candidateValue) {
|
|
104
|
+
return `pref-hyp-${createHash('sha256')
|
|
105
|
+
.update(`preference-hypothesis-v1\0${scopeKey}\0${preferenceKey}\0${candidateValue}`)
|
|
106
|
+
.digest('hex')}`;
|
|
107
|
+
}
|
|
108
|
+
function assertSignalProvenance(input) {
|
|
109
|
+
if (input.source === 'direct-owner-feedback') {
|
|
110
|
+
if (input.actorTrust !== 'owner-authenticated') {
|
|
111
|
+
throw new PreferenceStoreError('invalid-input', 'direct-owner-feedback requires owner-authenticated actor trust');
|
|
112
|
+
}
|
|
113
|
+
if (!['explicit-selection', 'typed-feedback'].includes(input.interpretationTrust)) {
|
|
114
|
+
throw new PreferenceStoreError('invalid-input', 'direct-owner-feedback requires explicit or typed interpretation');
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (input.source === 'signed-ui-feedback'
|
|
118
|
+
&& !['owner-authenticated', 'delegated-authenticated'].includes(input.actorTrust)) {
|
|
119
|
+
throw new PreferenceStoreError('invalid-input', 'signed-ui-feedback requires authenticated actor trust');
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Only an authenticated owner's explicit, typed feedback can unlock an
|
|
124
|
+
* automatic T1 effect. Lower-trust observations remain useful shadow evidence
|
|
125
|
+
* and contradictions, but no amount of repetition can promote them by itself.
|
|
126
|
+
*/
|
|
127
|
+
function isActivationEligibleSignal(signal) {
|
|
128
|
+
return signal.actor_trust === 'owner-authenticated'
|
|
129
|
+
&& ['explicit-selection', 'typed-feedback'].includes(signal.interpretation_trust)
|
|
130
|
+
&& ['direct-owner-feedback', 'signed-ui-feedback'].includes(signal.source);
|
|
131
|
+
}
|
|
132
|
+
export class PreferenceStore {
|
|
133
|
+
#database;
|
|
134
|
+
#now;
|
|
135
|
+
#options;
|
|
136
|
+
#closed = false;
|
|
137
|
+
constructor(options) {
|
|
138
|
+
this.#database = openPreferenceDatabase(options.path);
|
|
139
|
+
this.#now = options.now ?? Date.now;
|
|
140
|
+
this.#options = Object.freeze({
|
|
141
|
+
signalTtlMs: options.signalTtlMs,
|
|
142
|
+
hypothesisTtlMs: options.hypothesisTtlMs,
|
|
143
|
+
minSignalsForActivation: options.minSignalsForActivation,
|
|
144
|
+
minConfidenceBps: options.minConfidenceBps,
|
|
145
|
+
maxContradictionBps: options.maxContradictionBps,
|
|
146
|
+
maxActiveOverlays: options.maxActiveOverlays,
|
|
147
|
+
maxReviewHypotheses: options.maxReviewHypotheses,
|
|
148
|
+
maxOverlayBytes: options.maxOverlayBytes,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
appendSignal(input) {
|
|
152
|
+
return this.appendSignals([input])[0];
|
|
153
|
+
}
|
|
154
|
+
/** Atomically record one small producer batch and reconcile each affected key once. */
|
|
155
|
+
appendSignals(inputs) {
|
|
156
|
+
this.#assertOpen();
|
|
157
|
+
if (!Array.isArray(inputs) || inputs.length < 1 || inputs.length > 16) {
|
|
158
|
+
throw new PreferenceStoreError('invalid-input', 'preference signal batch must contain 1-16 entries');
|
|
159
|
+
}
|
|
160
|
+
const now = this.#now();
|
|
161
|
+
const normalizedBatch = inputs.map(input => this.#normalizeSignal(input, now));
|
|
162
|
+
const uniqueIdempotency = new Set(normalizedBatch.map(input => input.idempotencyDigest));
|
|
163
|
+
if (uniqueIdempotency.size !== normalizedBatch.length) {
|
|
164
|
+
throw new PreferenceStoreError('invalid-input', 'preference signal batch contains duplicate idempotency keys');
|
|
165
|
+
}
|
|
166
|
+
this.#database.exec('BEGIN IMMEDIATE');
|
|
167
|
+
try {
|
|
168
|
+
const storedSignals = [];
|
|
169
|
+
const affected = new Map();
|
|
170
|
+
for (const normalized of normalizedBatch) {
|
|
171
|
+
const hash = payloadHash(normalized);
|
|
172
|
+
const replay = this.#database.prepare('SELECT * FROM preference_signals WHERE idempotency_key = ?').get(normalized.idempotencyDigest);
|
|
173
|
+
if (replay !== undefined) {
|
|
174
|
+
if (replay.payload_hash !== hash) {
|
|
175
|
+
throw new PreferenceStoreError('idempotency-conflict', 'preference signal idempotency key was reused with different content');
|
|
176
|
+
}
|
|
177
|
+
storedSignals.push(signalFromRow(replay));
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
const forgottenThrough = this.#forgottenThrough(normalized.scopeDigest);
|
|
181
|
+
if (forgottenThrough !== undefined && normalized.occurredAt <= forgottenThrough) {
|
|
182
|
+
throw new PreferenceStoreError('scope-forgotten', 'signal predates the durable scope forget boundary');
|
|
183
|
+
}
|
|
184
|
+
const id = `pref-sig-${randomUUID()}`;
|
|
185
|
+
this.#database.prepare(`
|
|
186
|
+
INSERT INTO preference_signals(
|
|
187
|
+
id, idempotency_key, payload_hash, scope_key, workspace, preset,
|
|
188
|
+
preference_key, candidate_value, risk_tier, stance, actor_trust,
|
|
189
|
+
interpretation_trust, source, occurred_at, recorded_at
|
|
190
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
191
|
+
`).run(id, normalized.idempotencyDigest, hash, normalized.scopeKey, normalized.scope.workspace, normalized.scope.preset, normalized.preferenceKey, normalized.candidateValue, normalized.riskTier, normalized.stance, normalized.actorTrust, normalized.interpretationTrust, normalized.source, normalized.occurredAt, now);
|
|
192
|
+
const stored = this.#database.prepare('SELECT * FROM preference_signals WHERE id = ?')
|
|
193
|
+
.get(id);
|
|
194
|
+
storedSignals.push(signalFromRow(stored));
|
|
195
|
+
if (normalized.riskTier === 'T1' || normalized.riskTier === 'T2') {
|
|
196
|
+
affected.set(`${normalized.scopeKey}\0${normalized.preferenceKey}`, {
|
|
197
|
+
scopeKey: normalized.scopeKey,
|
|
198
|
+
scope: normalized.scope,
|
|
199
|
+
preferenceKey: normalized.preferenceKey,
|
|
200
|
+
trigger: stored,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
for (const entry of affected.values()) {
|
|
205
|
+
this.#reconcileKey(entry.scopeKey, entry.scope, entry.preferenceKey, now, entry.trigger);
|
|
206
|
+
}
|
|
207
|
+
this.#database.exec('COMMIT');
|
|
208
|
+
return storedSignals;
|
|
209
|
+
}
|
|
210
|
+
catch (error) {
|
|
211
|
+
this.#database.exec('ROLLBACK');
|
|
212
|
+
throw error;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
list(scopeInput, limit = this.#options.maxReviewHypotheses) {
|
|
216
|
+
this.#assertOpen();
|
|
217
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > this.#options.maxReviewHypotheses) {
|
|
218
|
+
throw new PreferenceStoreError('invalid-input', `hypothesis limit must be between 1 and ${this.#options.maxReviewHypotheses}`);
|
|
219
|
+
}
|
|
220
|
+
const canonical = canonicalPreferenceScope(scopeInput);
|
|
221
|
+
this.#refreshScope(canonical.scopeKey, canonical.scope, this.#now());
|
|
222
|
+
const rows = this.#database.prepare(`
|
|
223
|
+
SELECT * FROM preference_hypotheses
|
|
224
|
+
WHERE scope_key = ?
|
|
225
|
+
ORDER BY CASE effect_state
|
|
226
|
+
WHEN 'active' THEN 0 WHEN 'shadow' THEN 1 WHEN 'inactive' THEN 2
|
|
227
|
+
WHEN 'suppressed' THEN 3 ELSE 4 END,
|
|
228
|
+
confidence_bps DESC, updated_at DESC, id DESC
|
|
229
|
+
LIMIT ?
|
|
230
|
+
`).all(canonical.scopeKey, limit);
|
|
231
|
+
return rows.map(hypothesisFromRow);
|
|
232
|
+
}
|
|
233
|
+
get(scopeInput, hypothesisIdInput) {
|
|
234
|
+
this.#assertOpen();
|
|
235
|
+
const canonical = canonicalPreferenceScope(scopeInput);
|
|
236
|
+
const id = boundedText(hypothesisIdInput, 'hypothesisId', 200);
|
|
237
|
+
this.#refreshScope(canonical.scopeKey, canonical.scope, this.#now());
|
|
238
|
+
const row = this.#database.prepare('SELECT * FROM preference_hypotheses WHERE id = ? AND scope_key = ?').get(id, canonical.scopeKey);
|
|
239
|
+
return row === undefined ? undefined : hypothesisFromRow(row);
|
|
240
|
+
}
|
|
241
|
+
activate(scopeInput, hypothesisIdInput, expectedVersion) {
|
|
242
|
+
this.#assertOpen();
|
|
243
|
+
const canonical = canonicalPreferenceScope(scopeInput);
|
|
244
|
+
const id = boundedText(hypothesisIdInput, 'hypothesisId', 200);
|
|
245
|
+
if (!Number.isSafeInteger(expectedVersion) || expectedVersion < 1) {
|
|
246
|
+
throw new PreferenceStoreError('invalid-input', 'expectedVersion must be a positive safe integer');
|
|
247
|
+
}
|
|
248
|
+
const now = this.#now();
|
|
249
|
+
this.#database.exec('BEGIN IMMEDIATE');
|
|
250
|
+
try {
|
|
251
|
+
this.#refreshScopeInTransaction(canonical.scopeKey, canonical.scope, now);
|
|
252
|
+
const row = this.#database.prepare('SELECT * FROM preference_hypotheses WHERE id = ? AND scope_key = ?').get(id, canonical.scopeKey);
|
|
253
|
+
if (row === undefined)
|
|
254
|
+
throw new PreferenceStoreError('not-found', 'preference hypothesis was not found');
|
|
255
|
+
if (row.version !== expectedVersion)
|
|
256
|
+
throw new PreferenceStoreError('conflict', 'hypothesis version changed');
|
|
257
|
+
if (row.risk_tier !== 'T1') {
|
|
258
|
+
throw new PreferenceStoreError('forbidden-tier', 'only Host-catalog T1 hypotheses can activate automatically');
|
|
259
|
+
}
|
|
260
|
+
if (row.claim_state !== 'tentative' || row.effect_state !== 'shadow') {
|
|
261
|
+
throw new PreferenceStoreError('not-ready', 'hypothesis is not in an activatable shadow state');
|
|
262
|
+
}
|
|
263
|
+
if (row.expires_at <= now
|
|
264
|
+
|| row.supporting_signals < this.#options.minSignalsForActivation
|
|
265
|
+
|| row.confidence_bps < this.#options.minConfidenceBps
|
|
266
|
+
|| row.contradiction_bps > this.#options.maxContradictionBps) {
|
|
267
|
+
throw new PreferenceStoreError('not-ready', 'hypothesis has insufficient current evidence');
|
|
268
|
+
}
|
|
269
|
+
const latestSelection = this.#latestActivationEligibleSignal(canonical.scopeKey, row.preference_key, now);
|
|
270
|
+
if (latestSelection?.stance !== 'support'
|
|
271
|
+
|| latestSelection.candidate_value !== row.candidate_value) {
|
|
272
|
+
throw new PreferenceStoreError('not-ready', 'hypothesis is not the owner\'s latest explicit selection');
|
|
273
|
+
}
|
|
274
|
+
const activeCount = this.#database.prepare(`
|
|
275
|
+
SELECT COUNT(*) AS count FROM preference_hypotheses
|
|
276
|
+
WHERE scope_key = ? AND effect_state = 'active'
|
|
277
|
+
`).get(canonical.scopeKey).count;
|
|
278
|
+
if (activeCount >= this.#options.maxActiveOverlays) {
|
|
279
|
+
throw new PreferenceStoreError('not-ready', 'scope reached the active preference hard cap');
|
|
280
|
+
}
|
|
281
|
+
const sameKey = this.#database.prepare(`
|
|
282
|
+
SELECT id FROM preference_hypotheses
|
|
283
|
+
WHERE scope_key = ? AND preference_key = ? AND effect_state = 'active'
|
|
284
|
+
`).get(canonical.scopeKey, row.preference_key);
|
|
285
|
+
if (sameKey !== undefined)
|
|
286
|
+
throw new PreferenceStoreError('conflict', 'another value for this key is active');
|
|
287
|
+
const nextVersion = row.version + 1;
|
|
288
|
+
this.#database.prepare(`
|
|
289
|
+
UPDATE preference_hypotheses
|
|
290
|
+
SET effect_state = 'active', activated_at = ?, updated_at = ?, version = ?
|
|
291
|
+
WHERE id = ? AND version = ?
|
|
292
|
+
`).run(now, now, nextVersion, row.id, row.version);
|
|
293
|
+
this.#transition(row, row.claim_state, 'active', 'activated', nextVersion, now);
|
|
294
|
+
const updated = this.#database.prepare('SELECT * FROM preference_hypotheses WHERE id = ?')
|
|
295
|
+
.get(row.id);
|
|
296
|
+
this.#database.exec('COMMIT');
|
|
297
|
+
return hypothesisFromRow(updated);
|
|
298
|
+
}
|
|
299
|
+
catch (error) {
|
|
300
|
+
this.#database.exec('ROLLBACK');
|
|
301
|
+
throw error;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
rollback(scopeInput, hypothesisIdInput, expectedVersion, reasonInput) {
|
|
305
|
+
this.#assertOpen();
|
|
306
|
+
const canonical = canonicalPreferenceScope(scopeInput);
|
|
307
|
+
const id = boundedText(hypothesisIdInput, 'hypothesisId', 200);
|
|
308
|
+
const reason = enumValue(reasonInput, preferenceRollbackReasons, 'reason');
|
|
309
|
+
if (!Number.isSafeInteger(expectedVersion) || expectedVersion < 1) {
|
|
310
|
+
throw new PreferenceStoreError('invalid-input', 'expectedVersion must be a positive safe integer');
|
|
311
|
+
}
|
|
312
|
+
const now = this.#now();
|
|
313
|
+
this.#database.exec('BEGIN IMMEDIATE');
|
|
314
|
+
try {
|
|
315
|
+
const row = this.#database.prepare('SELECT * FROM preference_hypotheses WHERE id = ? AND scope_key = ?').get(id, canonical.scopeKey);
|
|
316
|
+
if (row === undefined)
|
|
317
|
+
throw new PreferenceStoreError('not-found', 'preference hypothesis was not found');
|
|
318
|
+
if (row.version !== expectedVersion)
|
|
319
|
+
throw new PreferenceStoreError('conflict', 'hypothesis version changed');
|
|
320
|
+
if (row.effect_state !== 'active' && row.effect_state !== 'shadow') {
|
|
321
|
+
throw new PreferenceStoreError('not-ready', 'only a shadow or active hypothesis can be rolled back');
|
|
322
|
+
}
|
|
323
|
+
const nextVersion = row.version + 1;
|
|
324
|
+
this.#database.prepare(`
|
|
325
|
+
UPDATE preference_hypotheses SET
|
|
326
|
+
claim_state = 'rejected', effect_state = 'rolled-back', rolled_back_at = ?, updated_at = ?, version = ?
|
|
327
|
+
WHERE id = ? AND version = ?
|
|
328
|
+
`).run(now, now, nextVersion, row.id, row.version);
|
|
329
|
+
this.#transition(row, 'rejected', 'rolled-back', reason, nextVersion, now);
|
|
330
|
+
const updated = this.#database.prepare('SELECT * FROM preference_hypotheses WHERE id = ?')
|
|
331
|
+
.get(row.id);
|
|
332
|
+
this.#database.exec('COMMIT');
|
|
333
|
+
return hypothesisFromRow(updated);
|
|
334
|
+
}
|
|
335
|
+
catch (error) {
|
|
336
|
+
this.#database.exec('ROLLBACK');
|
|
337
|
+
throw error;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
overlay(scopeInput) {
|
|
341
|
+
this.#assertOpen();
|
|
342
|
+
const canonical = canonicalPreferenceScope(scopeInput);
|
|
343
|
+
this.#refreshScope(canonical.scopeKey, canonical.scope, this.#now());
|
|
344
|
+
const rows = this.#database.prepare(`
|
|
345
|
+
SELECT * FROM preference_hypotheses
|
|
346
|
+
WHERE scope_key = ? AND effect_state = 'active' AND risk_tier = 'T1'
|
|
347
|
+
ORDER BY preference_key ASC, id ASC
|
|
348
|
+
LIMIT ?
|
|
349
|
+
`).all(canonical.scopeKey, this.#options.maxActiveOverlays);
|
|
350
|
+
if (rows.length === 0)
|
|
351
|
+
return undefined;
|
|
352
|
+
const lines = rows.map(row => `- ${renderCatalogPreference(row.preference_key, row.candidate_value)}`);
|
|
353
|
+
const rendered = [
|
|
354
|
+
'<tentative_preference_overlay>',
|
|
355
|
+
'These low-risk preferences are tentative, scope-local, and subordinate to the current request.',
|
|
356
|
+
...lines,
|
|
357
|
+
'</tentative_preference_overlay>',
|
|
358
|
+
].join('\n');
|
|
359
|
+
if (Buffer.byteLength(rendered, 'utf8') > this.#options.maxOverlayBytes) {
|
|
360
|
+
throw new PreferenceStoreError('invalid-input', 'catalog overlay exceeds the configured hard byte cap');
|
|
361
|
+
}
|
|
362
|
+
return rendered;
|
|
363
|
+
}
|
|
364
|
+
forgetScope(scopeInput, idempotencyKeyInput) {
|
|
365
|
+
this.#assertOpen();
|
|
366
|
+
const canonical = canonicalPreferenceScope(scopeInput);
|
|
367
|
+
const rawIdempotencyKey = boundedText(idempotencyKeyInput, 'idempotencyKey', 200);
|
|
368
|
+
const idempotencyKey = `pref-forget-idem-${createHash('sha256')
|
|
369
|
+
.update(`preference-forget-idempotency-v1\0${rawIdempotencyKey}`).digest('hex')}`;
|
|
370
|
+
const now = this.#now();
|
|
371
|
+
const forgottenThrough = now;
|
|
372
|
+
const hash = payloadHash({ scopeDigest: canonical.scopeDigest, idempotencyKey });
|
|
373
|
+
let result;
|
|
374
|
+
this.#database.exec('BEGIN IMMEDIATE');
|
|
375
|
+
try {
|
|
376
|
+
const replay = this.#database.prepare(`
|
|
377
|
+
SELECT payload_hash, forgotten_through, deleted_signals, deleted_hypotheses
|
|
378
|
+
FROM preference_scope_tombstones WHERE idempotency_key = ?
|
|
379
|
+
`).get(idempotencyKey);
|
|
380
|
+
if (replay !== undefined) {
|
|
381
|
+
if (replay.payload_hash !== hash) {
|
|
382
|
+
throw new PreferenceStoreError('idempotency-conflict', 'forget idempotency key was reused');
|
|
383
|
+
}
|
|
384
|
+
this.#database.exec('COMMIT');
|
|
385
|
+
result = Object.freeze({
|
|
386
|
+
replayed: true,
|
|
387
|
+
forgottenThrough: replay.forgotten_through,
|
|
388
|
+
deletedSignals: replay.deleted_signals,
|
|
389
|
+
deletedHypotheses: replay.deleted_hypotheses,
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
else {
|
|
393
|
+
const hypothesisIds = this.#database.prepare('SELECT id FROM preference_hypotheses WHERE scope_key = ?').all(canonical.scopeKey);
|
|
394
|
+
for (const entry of hypothesisIds) {
|
|
395
|
+
this.#database.prepare('DELETE FROM preference_transitions WHERE hypothesis_id = ?').run(entry.id);
|
|
396
|
+
}
|
|
397
|
+
const deletedHypotheses = Number(this.#database.prepare('DELETE FROM preference_hypotheses WHERE scope_key = ?').run(canonical.scopeKey).changes);
|
|
398
|
+
const deletedSignals = Number(this.#database.prepare('DELETE FROM preference_signals WHERE scope_key = ?').run(canonical.scopeKey).changes);
|
|
399
|
+
this.#database.prepare(`
|
|
400
|
+
INSERT INTO preference_scope_tombstones(
|
|
401
|
+
id, scope_digest, idempotency_key, payload_hash, forgotten_through,
|
|
402
|
+
recorded_at, deleted_signals, deleted_hypotheses
|
|
403
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
404
|
+
`).run(`pref-forget-${randomUUID()}`, canonical.scopeDigest, idempotencyKey, hash, forgottenThrough, now, deletedSignals, deletedHypotheses);
|
|
405
|
+
this.#database.exec('COMMIT');
|
|
406
|
+
result = Object.freeze({
|
|
407
|
+
replayed: false,
|
|
408
|
+
forgottenThrough,
|
|
409
|
+
deletedSignals,
|
|
410
|
+
deletedHypotheses,
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
catch (error) {
|
|
415
|
+
this.#database.exec('ROLLBACK');
|
|
416
|
+
throw error;
|
|
417
|
+
}
|
|
418
|
+
// A logical DELETE is not a privacy boundary while old pages remain in
|
|
419
|
+
// WAL. Secure-delete wipes the database pages; TRUNCATE proves those
|
|
420
|
+
// deletion frames have also been checkpointed before success is reported.
|
|
421
|
+
this.#truncateWalForPrivacy();
|
|
422
|
+
return result;
|
|
423
|
+
}
|
|
424
|
+
health() {
|
|
425
|
+
this.#assertOpen();
|
|
426
|
+
const signals = this.#database.prepare('SELECT COUNT(*) AS count, MAX(recorded_at) AS last FROM preference_signals').get();
|
|
427
|
+
const hypotheses = this.#database.prepare(`
|
|
428
|
+
SELECT
|
|
429
|
+
COUNT(*) AS total,
|
|
430
|
+
SUM(CASE WHEN effect_state = 'active' THEN 1 ELSE 0 END) AS active,
|
|
431
|
+
SUM(CASE WHEN effect_state = 'shadow' THEN 1 ELSE 0 END) AS shadow,
|
|
432
|
+
SUM(CASE WHEN claim_state = 'proposed' THEN 1 ELSE 0 END) AS proposed,
|
|
433
|
+
SUM(CASE WHEN effect_state = 'rolled-back' THEN 1 ELSE 0 END) AS rolled_back,
|
|
434
|
+
SUM(CASE WHEN claim_state = 'expired' THEN 1 ELSE 0 END) AS expired
|
|
435
|
+
FROM preference_hypotheses
|
|
436
|
+
`).get();
|
|
437
|
+
return Object.freeze({
|
|
438
|
+
ready: true,
|
|
439
|
+
enabled: true,
|
|
440
|
+
schemaVersion: preferenceSchemaVersion,
|
|
441
|
+
signals: signals.count,
|
|
442
|
+
hypotheses: hypotheses.total,
|
|
443
|
+
active: hypotheses.active ?? 0,
|
|
444
|
+
shadow: hypotheses.shadow ?? 0,
|
|
445
|
+
proposed: hypotheses.proposed ?? 0,
|
|
446
|
+
rolledBack: hypotheses.rolled_back ?? 0,
|
|
447
|
+
expired: hypotheses.expired ?? 0,
|
|
448
|
+
lastRecordedAt: signals.last ?? undefined,
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
/** Delete one bounded batch whose evidence-retention window has elapsed. */
|
|
452
|
+
maintain(limit = 500) {
|
|
453
|
+
this.#assertOpen();
|
|
454
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 5_000) {
|
|
455
|
+
throw new PreferenceStoreError('invalid-input', 'maintenance limit must be between 1 and 5000');
|
|
456
|
+
}
|
|
457
|
+
const threshold = Math.max(0, this.#now() - this.#options.signalTtlMs);
|
|
458
|
+
let deletedSignals;
|
|
459
|
+
this.#database.exec('BEGIN IMMEDIATE');
|
|
460
|
+
try {
|
|
461
|
+
deletedSignals = Number(this.#database.prepare(`
|
|
462
|
+
DELETE FROM preference_signals
|
|
463
|
+
WHERE id IN (
|
|
464
|
+
SELECT id FROM preference_signals
|
|
465
|
+
WHERE occurred_at < ?
|
|
466
|
+
ORDER BY occurred_at ASC, id ASC
|
|
467
|
+
LIMIT ?
|
|
468
|
+
)
|
|
469
|
+
`).run(threshold, limit).changes);
|
|
470
|
+
this.#database.exec('COMMIT');
|
|
471
|
+
}
|
|
472
|
+
catch (error) {
|
|
473
|
+
this.#database.exec('ROLLBACK');
|
|
474
|
+
throw error;
|
|
475
|
+
}
|
|
476
|
+
// Run this even when the current batch is empty: a previous committed
|
|
477
|
+
// deletion may have reported privacy-purge-pending because a reader held
|
|
478
|
+
// the WAL, and the next maintenance pass must finish that purge.
|
|
479
|
+
this.#truncateWalForPrivacy();
|
|
480
|
+
return Object.freeze({ deletedSignals });
|
|
481
|
+
}
|
|
482
|
+
close() {
|
|
483
|
+
if (this.#closed)
|
|
484
|
+
return;
|
|
485
|
+
this.#closed = true;
|
|
486
|
+
this.#database.close();
|
|
487
|
+
}
|
|
488
|
+
#normalizeSignal(input, now) {
|
|
489
|
+
if (typeof input !== 'object' || input === null || Array.isArray(input)) {
|
|
490
|
+
throw new PreferenceStoreError('invalid-input', 'preference signal must be an object');
|
|
491
|
+
}
|
|
492
|
+
const canonical = canonicalPreferenceScope(input.scope);
|
|
493
|
+
let selection;
|
|
494
|
+
try {
|
|
495
|
+
selection = catalogSelection(input.preferenceKey, input.candidateValue);
|
|
496
|
+
}
|
|
497
|
+
catch (error) {
|
|
498
|
+
throw new PreferenceStoreError('invalid-input', error instanceof Error ? error.message : String(error));
|
|
499
|
+
}
|
|
500
|
+
if (selection.riskTier === 'T3') {
|
|
501
|
+
throw new PreferenceStoreError('forbidden-tier', 'T3 controls are Host-defined and cannot become evidence');
|
|
502
|
+
}
|
|
503
|
+
const occurredAt = safeTimestamp(input.occurredAt, 'occurredAt');
|
|
504
|
+
if (occurredAt > now) {
|
|
505
|
+
throw new PreferenceStoreError('invalid-input', 'occurredAt must not be in the future');
|
|
506
|
+
}
|
|
507
|
+
if (now - occurredAt > this.#options.signalTtlMs) {
|
|
508
|
+
throw new PreferenceStoreError('invalid-input', 'signal is older than the evidence retention window');
|
|
509
|
+
}
|
|
510
|
+
const normalized = {
|
|
511
|
+
...canonical,
|
|
512
|
+
preferenceKey: selection.key,
|
|
513
|
+
candidateValue: selection.value,
|
|
514
|
+
riskTier: selection.riskTier,
|
|
515
|
+
stance: enumValue(input.stance, preferenceSignalStances, 'stance'),
|
|
516
|
+
actorTrust: enumValue(input.actorTrust, preferenceActorTrustLevels, 'actorTrust'),
|
|
517
|
+
interpretationTrust: enumValue(input.interpretationTrust, preferenceInterpretationTrustLevels, 'interpretationTrust'),
|
|
518
|
+
source: enumValue(input.source, preferenceSignalSources, 'source'),
|
|
519
|
+
occurredAt,
|
|
520
|
+
idempotencyDigest: `pref-idem-${createHash('sha256')
|
|
521
|
+
.update(`preference-signal-idempotency-v1\0${boundedText(input.idempotencyKey, 'idempotencyKey', 200)}`)
|
|
522
|
+
.digest('hex')}`,
|
|
523
|
+
};
|
|
524
|
+
assertSignalProvenance(normalized);
|
|
525
|
+
return normalized;
|
|
526
|
+
}
|
|
527
|
+
#refreshScope(scopeKey, scope, now) {
|
|
528
|
+
this.#database.exec('BEGIN IMMEDIATE');
|
|
529
|
+
try {
|
|
530
|
+
this.#refreshScopeInTransaction(scopeKey, scope, now);
|
|
531
|
+
this.#database.exec('COMMIT');
|
|
532
|
+
}
|
|
533
|
+
catch (error) {
|
|
534
|
+
this.#database.exec('ROLLBACK');
|
|
535
|
+
throw error;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
#refreshScopeInTransaction(scopeKey, scope, now) {
|
|
539
|
+
const keys = this.#database.prepare(`
|
|
540
|
+
SELECT DISTINCT preference_key FROM preference_hypotheses WHERE scope_key = ?
|
|
541
|
+
UNION
|
|
542
|
+
SELECT DISTINCT preference_key FROM preference_signals
|
|
543
|
+
WHERE scope_key = ? AND risk_tier IN ('T1', 'T2')
|
|
544
|
+
`).all(scopeKey, scopeKey);
|
|
545
|
+
for (const { preference_key: key } of keys)
|
|
546
|
+
this.#reconcileKey(scopeKey, scope, key, now);
|
|
547
|
+
}
|
|
548
|
+
#reconcileKey(scopeKey, scope, key, now, triggerSignal) {
|
|
549
|
+
const signals = this.#database.prepare(`
|
|
550
|
+
SELECT * FROM preference_signals
|
|
551
|
+
WHERE scope_key = ? AND preference_key = ? AND occurred_at >= ?
|
|
552
|
+
ORDER BY occurred_at ASC, id ASC
|
|
553
|
+
`).all(scopeKey, key, Math.max(0, now - this.#options.signalTtlMs));
|
|
554
|
+
const entry = preferenceCatalog[key];
|
|
555
|
+
if (entry.riskTier !== 'T1' && entry.riskTier !== 'T2')
|
|
556
|
+
return;
|
|
557
|
+
const existing = this.#database.prepare(`
|
|
558
|
+
SELECT * FROM preference_hypotheses WHERE scope_key = ? AND preference_key = ?
|
|
559
|
+
`).all(scopeKey, key);
|
|
560
|
+
const latestSelection = this.#latestActivationEligibleSignal(scopeKey, key, now);
|
|
561
|
+
const candidates = new Set(existing.map(row => row.candidate_value));
|
|
562
|
+
for (const signal of signals)
|
|
563
|
+
if (signal.stance === 'support')
|
|
564
|
+
candidates.add(signal.candidate_value);
|
|
565
|
+
for (const candidate of candidates) {
|
|
566
|
+
const evidence = this.#evidenceForCandidate(signals, candidate, now);
|
|
567
|
+
const latestSupportingAt = signals.reduce((latest, signal) => {
|
|
568
|
+
if (signal.stance !== 'support' || signal.candidate_value !== candidate)
|
|
569
|
+
return latest;
|
|
570
|
+
return latest === undefined ? signal.occurred_at : Math.max(latest, signal.occurred_at);
|
|
571
|
+
}, undefined);
|
|
572
|
+
const latestEligibleSupportingAt = signals.reduce((latest, signal) => {
|
|
573
|
+
if (!isActivationEligibleSignal(signal)
|
|
574
|
+
|| signal.stance !== 'support'
|
|
575
|
+
|| signal.candidate_value !== candidate)
|
|
576
|
+
return latest;
|
|
577
|
+
return latest === undefined ? signal.occurred_at : Math.max(latest, signal.occurred_at);
|
|
578
|
+
}, undefined);
|
|
579
|
+
const prior = existing.find(row => row.candidate_value === candidate);
|
|
580
|
+
if (prior === undefined) {
|
|
581
|
+
if (latestSupportingAt === undefined)
|
|
582
|
+
continue;
|
|
583
|
+
const expiryAnchor = entry.riskTier === 'T1'
|
|
584
|
+
? latestEligibleSupportingAt ?? latestSupportingAt
|
|
585
|
+
: latestSupportingAt;
|
|
586
|
+
const expiresAt = expiryAnchor + this.#options.hypothesisTtlMs;
|
|
587
|
+
const expired = expiresAt <= now;
|
|
588
|
+
const claimState = expired
|
|
589
|
+
? 'expired'
|
|
590
|
+
: entry.riskTier === 'T2' ? 'proposed' : 'tentative';
|
|
591
|
+
const effectState = expired
|
|
592
|
+
? 'inactive'
|
|
593
|
+
: entry.riskTier === 'T2' ? 'inactive' : 'shadow';
|
|
594
|
+
const id = hypothesisId(scopeKey, key, candidate);
|
|
595
|
+
this.#database.prepare(`
|
|
596
|
+
INSERT INTO preference_hypotheses(
|
|
597
|
+
id, scope_key, workspace, preset, preference_key, candidate_value, risk_tier,
|
|
598
|
+
claim_state, effect_state, confidence_bps, contradiction_bps,
|
|
599
|
+
supporting_signals, contradicting_signals, evidence_mass, expires_at,
|
|
600
|
+
activated_at, rolled_back_at, version, created_at, updated_at
|
|
601
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, 1, ?, ?)
|
|
602
|
+
`).run(id, scopeKey, scope.workspace, scope.preset, key, candidate, entry.riskTier, claimState, effectState, evidence.confidenceBps, evidence.contradictionBps, evidence.supportingSignals, evidence.contradictingSignals, evidence.mass, expiresAt, now, now);
|
|
603
|
+
this.#database.prepare(`
|
|
604
|
+
INSERT INTO preference_transitions(
|
|
605
|
+
hypothesis_id, from_claim_state, to_claim_state, from_effect_state,
|
|
606
|
+
to_effect_state, reason, version, occurred_at
|
|
607
|
+
) VALUES (?, NULL, ?, NULL, ?, 'created', 1, ?)
|
|
608
|
+
`).run(id, claimState, effectState, now);
|
|
609
|
+
continue;
|
|
610
|
+
}
|
|
611
|
+
const expiryAnchor = entry.riskTier === 'T1'
|
|
612
|
+
? latestEligibleSupportingAt ?? latestSupportingAt
|
|
613
|
+
: latestSupportingAt;
|
|
614
|
+
const expiresAt = expiryAnchor === undefined
|
|
615
|
+
? prior.expires_at
|
|
616
|
+
: expiryAnchor + this.#options.hypothesisTtlMs;
|
|
617
|
+
let claimState = prior.claim_state;
|
|
618
|
+
let effectState = prior.effect_state;
|
|
619
|
+
let activatedAt = prior.activated_at;
|
|
620
|
+
let rolledBackAt = prior.rolled_back_at;
|
|
621
|
+
let version = prior.version;
|
|
622
|
+
let transitionReason;
|
|
623
|
+
const ownerCorrection = triggerSignal !== undefined
|
|
624
|
+
&& latestSelection?.id === triggerSignal.id
|
|
625
|
+
&& isActivationEligibleSignal(triggerSignal)
|
|
626
|
+
&& ((triggerSignal.stance === 'support' && triggerSignal.candidate_value !== candidate)
|
|
627
|
+
|| (triggerSignal.stance === 'contradict' && triggerSignal.candidate_value === candidate));
|
|
628
|
+
const ownerReselected = triggerSignal !== undefined
|
|
629
|
+
&& latestSelection?.id === triggerSignal.id
|
|
630
|
+
&& isActivationEligibleSignal(triggerSignal)
|
|
631
|
+
&& triggerSignal.stance === 'support'
|
|
632
|
+
&& triggerSignal.candidate_value === candidate;
|
|
633
|
+
if (!['rejected', 'expired'].includes(claimState) && expiresAt <= now) {
|
|
634
|
+
claimState = 'expired';
|
|
635
|
+
effectState = 'inactive';
|
|
636
|
+
version += 1;
|
|
637
|
+
transitionReason = 'expired';
|
|
638
|
+
}
|
|
639
|
+
else if (effectState === 'active' && ownerCorrection) {
|
|
640
|
+
claimState = 'rejected';
|
|
641
|
+
effectState = 'rolled-back';
|
|
642
|
+
rolledBackAt = now;
|
|
643
|
+
version += 1;
|
|
644
|
+
transitionReason = 'owner-rejected';
|
|
645
|
+
}
|
|
646
|
+
else if (entry.riskTier === 'T1'
|
|
647
|
+
&& ['rejected', 'expired'].includes(claimState)
|
|
648
|
+
&& ownerReselected
|
|
649
|
+
&& expiresAt > now) {
|
|
650
|
+
claimState = 'tentative';
|
|
651
|
+
effectState = 'shadow';
|
|
652
|
+
activatedAt = null;
|
|
653
|
+
rolledBackAt = null;
|
|
654
|
+
version += 1;
|
|
655
|
+
transitionReason = 'evidence-updated';
|
|
656
|
+
}
|
|
657
|
+
const changed = prior.confidence_bps !== evidence.confidenceBps
|
|
658
|
+
|| prior.contradiction_bps !== evidence.contradictionBps
|
|
659
|
+
|| prior.supporting_signals !== evidence.supportingSignals
|
|
660
|
+
|| prior.contradicting_signals !== evidence.contradictingSignals
|
|
661
|
+
|| prior.evidence_mass !== evidence.mass
|
|
662
|
+
|| prior.expires_at !== expiresAt
|
|
663
|
+
|| prior.claim_state !== claimState
|
|
664
|
+
|| prior.effect_state !== effectState
|
|
665
|
+
|| prior.activated_at !== activatedAt
|
|
666
|
+
|| prior.rolled_back_at !== rolledBackAt;
|
|
667
|
+
if (!changed)
|
|
668
|
+
continue;
|
|
669
|
+
if (transitionReason === undefined) {
|
|
670
|
+
version += 1;
|
|
671
|
+
transitionReason = 'evidence-updated';
|
|
672
|
+
}
|
|
673
|
+
this.#database.prepare(`
|
|
674
|
+
UPDATE preference_hypotheses SET
|
|
675
|
+
claim_state = ?, effect_state = ?, confidence_bps = ?, contradiction_bps = ?,
|
|
676
|
+
supporting_signals = ?, contradicting_signals = ?, evidence_mass = ?, expires_at = ?,
|
|
677
|
+
activated_at = ?, rolled_back_at = ?, version = ?, updated_at = ?
|
|
678
|
+
WHERE id = ?
|
|
679
|
+
`).run(claimState, effectState, evidence.confidenceBps, evidence.contradictionBps, evidence.supportingSignals, evidence.contradictingSignals, evidence.mass, expiresAt, activatedAt, rolledBackAt, version, now, prior.id);
|
|
680
|
+
this.#transition(prior, claimState, effectState, transitionReason, version, now);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
#evidenceForCandidate(signals, candidate, now) {
|
|
684
|
+
let positive = 0;
|
|
685
|
+
let negative = 0;
|
|
686
|
+
let observedMass = 0;
|
|
687
|
+
let supportingSignals = 0;
|
|
688
|
+
let contradictingSignals = 0;
|
|
689
|
+
for (const signal of signals) {
|
|
690
|
+
const age = Math.max(0, now - signal.occurred_at);
|
|
691
|
+
if (age >= this.#options.signalTtlMs)
|
|
692
|
+
continue;
|
|
693
|
+
const base = Math.floor(actorWeights[signal.actor_trust] * interpretationWeights[signal.interpretation_trust] / 100);
|
|
694
|
+
const decayPermille = Math.floor((this.#options.signalTtlMs - age) * 1_000 / this.#options.signalTtlMs);
|
|
695
|
+
const weight = Math.floor(base * decayPermille / 1_000);
|
|
696
|
+
if (weight <= 0)
|
|
697
|
+
continue;
|
|
698
|
+
observedMass += weight;
|
|
699
|
+
if (!isActivationEligibleSignal(signal))
|
|
700
|
+
continue;
|
|
701
|
+
const supports = signal.candidate_value === candidate && signal.stance === 'support';
|
|
702
|
+
const contradicts = (signal.candidate_value === candidate && signal.stance === 'contradict')
|
|
703
|
+
|| (signal.candidate_value !== candidate && signal.stance === 'support');
|
|
704
|
+
if (supports) {
|
|
705
|
+
positive += weight;
|
|
706
|
+
supportingSignals += 1;
|
|
707
|
+
}
|
|
708
|
+
else if (contradicts) {
|
|
709
|
+
negative += weight;
|
|
710
|
+
contradictingSignals += 1;
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
const decisionMass = positive + negative;
|
|
714
|
+
const rawConfidenceBps = decisionMass === 0 ? 0 : Math.floor(positive * 10_000 / decisionMass);
|
|
715
|
+
const coverageTarget = this.#options.minSignalsForActivation * 1_200;
|
|
716
|
+
const coverageBps = Math.min(10_000, Math.floor(decisionMass * 10_000 / coverageTarget));
|
|
717
|
+
return {
|
|
718
|
+
mass: observedMass,
|
|
719
|
+
supportingSignals,
|
|
720
|
+
contradictingSignals,
|
|
721
|
+
confidenceBps: Math.floor(rawConfidenceBps * coverageBps / 10_000),
|
|
722
|
+
contradictionBps: decisionMass === 0 ? 0 : Math.floor(negative * 10_000 / decisionMass),
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
#latestActivationEligibleSignal(scopeKey, key, now) {
|
|
726
|
+
return this.#database.prepare(`
|
|
727
|
+
SELECT * FROM preference_signals
|
|
728
|
+
WHERE scope_key = ? AND preference_key = ? AND occurred_at >= ?
|
|
729
|
+
AND actor_trust = 'owner-authenticated'
|
|
730
|
+
AND interpretation_trust IN ('explicit-selection', 'typed-feedback')
|
|
731
|
+
AND source IN ('direct-owner-feedback', 'signed-ui-feedback')
|
|
732
|
+
ORDER BY occurred_at DESC, recorded_at DESC, rowid DESC
|
|
733
|
+
LIMIT 1
|
|
734
|
+
`).get(scopeKey, key, Math.max(0, now - this.#options.signalTtlMs));
|
|
735
|
+
}
|
|
736
|
+
#transition(prior, claimState, effectState, reason, version, occurredAt) {
|
|
737
|
+
this.#database.prepare(`
|
|
738
|
+
INSERT INTO preference_transitions(
|
|
739
|
+
hypothesis_id, from_claim_state, to_claim_state, from_effect_state,
|
|
740
|
+
to_effect_state, reason, version, occurred_at
|
|
741
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
742
|
+
`).run(prior.id, prior.claim_state, claimState, prior.effect_state, effectState, reason, version, occurredAt);
|
|
743
|
+
}
|
|
744
|
+
#forgottenThrough(scopeDigest) {
|
|
745
|
+
const row = this.#database.prepare(`
|
|
746
|
+
SELECT MAX(forgotten_through) AS forgotten_through
|
|
747
|
+
FROM preference_scope_tombstones WHERE scope_digest = ?
|
|
748
|
+
`).get(scopeDigest);
|
|
749
|
+
return row.forgotten_through ?? undefined;
|
|
750
|
+
}
|
|
751
|
+
#assertOpen() {
|
|
752
|
+
if (this.#closed)
|
|
753
|
+
throw new PreferenceStoreError('disabled', 'preference store is closed');
|
|
754
|
+
}
|
|
755
|
+
#truncateWalForPrivacy() {
|
|
756
|
+
try {
|
|
757
|
+
const checkpoint = this.#database.prepare('PRAGMA wal_checkpoint(TRUNCATE)').get();
|
|
758
|
+
if (checkpoint.busy !== 0) {
|
|
759
|
+
throw new PreferenceStoreError('privacy-purge-pending', 'preference data is logically forgotten but the WAL privacy purge is still busy; retry the exact request');
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
catch (error) {
|
|
763
|
+
if (error instanceof PreferenceStoreError)
|
|
764
|
+
throw error;
|
|
765
|
+
throw new PreferenceStoreError('privacy-purge-pending', 'preference data is logically forgotten but its WAL privacy purge could not be verified; retry the exact request');
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
//# sourceMappingURL=store.js.map
|