@dsh-enhanced/assistant-evaluation 0.1.65 → 0.1.67

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 (89) hide show
  1. package/README.md +69 -1
  2. package/lib/attention-owner-questions.d.ts +104 -0
  3. package/lib/attention-owner-questions.d.ts.map +1 -0
  4. package/lib/attention-owner-questions.js +179 -0
  5. package/lib/attention-owner-questions.js.map +1 -0
  6. package/lib/attention-tuning.d.ts +492 -0
  7. package/lib/attention-tuning.d.ts.map +1 -0
  8. package/lib/attention-tuning.js +1044 -0
  9. package/lib/attention-tuning.js.map +1 -0
  10. package/lib/benchmark/cli.d.ts.map +1 -1
  11. package/lib/benchmark/cli.js +14 -11
  12. package/lib/benchmark/cli.js.map +1 -1
  13. package/lib/benchmark/corpus.d.ts +13 -2
  14. package/lib/benchmark/corpus.d.ts.map +1 -1
  15. package/lib/benchmark/corpus.js +19 -9
  16. package/lib/benchmark/corpus.js.map +1 -1
  17. package/lib/benchmark/families-corpus.d.ts +11 -0
  18. package/lib/benchmark/families-corpus.d.ts.map +1 -0
  19. package/lib/benchmark/families-corpus.js +133 -0
  20. package/lib/benchmark/families-corpus.js.map +1 -0
  21. package/lib/benchmark/holdout-isolation.d.ts +27 -0
  22. package/lib/benchmark/holdout-isolation.d.ts.map +1 -0
  23. package/lib/benchmark/holdout-isolation.js +66 -0
  24. package/lib/benchmark/holdout-isolation.js.map +1 -0
  25. package/lib/benchmark/holdout.d.ts.map +1 -1
  26. package/lib/benchmark/holdout.js +3 -0
  27. package/lib/benchmark/holdout.js.map +1 -1
  28. package/lib/benchmark/index.d.ts +3 -0
  29. package/lib/benchmark/index.d.ts.map +1 -1
  30. package/lib/benchmark/index.js +2 -0
  31. package/lib/benchmark/index.js.map +1 -1
  32. package/lib/benchmark/native-skills.d.ts +12 -14
  33. package/lib/benchmark/native-skills.d.ts.map +1 -1
  34. package/lib/benchmark/native-skills.js +1 -3
  35. package/lib/benchmark/native-skills.js.map +1 -1
  36. package/lib/benchmark/native.d.ts +15 -2
  37. package/lib/benchmark/native.d.ts.map +1 -1
  38. package/lib/benchmark/native.js +67 -14
  39. package/lib/benchmark/native.js.map +1 -1
  40. package/lib/benchmark/report.d.ts.map +1 -1
  41. package/lib/benchmark/report.js +98 -8
  42. package/lib/benchmark/report.js.map +1 -1
  43. package/lib/benchmark/runner.d.ts.map +1 -1
  44. package/lib/benchmark/runner.js +5 -0
  45. package/lib/benchmark/runner.js.map +1 -1
  46. package/lib/benchmark/strategy-capabilities.d.ts.map +1 -1
  47. package/lib/benchmark/strategy-capabilities.js +0 -5
  48. package/lib/benchmark/strategy-capabilities.js.map +1 -1
  49. package/lib/benchmark/types.d.ts +64 -7
  50. package/lib/benchmark/types.d.ts.map +1 -1
  51. package/lib/index.d.ts +8 -1
  52. package/lib/index.d.ts.map +1 -1
  53. package/lib/index.js +4 -0
  54. package/lib/index.js.map +1 -1
  55. package/lib/intake-attention-agreement.d.ts +63 -0
  56. package/lib/intake-attention-agreement.d.ts.map +1 -0
  57. package/lib/intake-attention-agreement.js +146 -0
  58. package/lib/intake-attention-agreement.js.map +1 -0
  59. package/lib/notification-outcomes.d.ts +56 -0
  60. package/lib/notification-outcomes.d.ts.map +1 -0
  61. package/lib/notification-outcomes.js +348 -0
  62. package/lib/notification-outcomes.js.map +1 -0
  63. package/lib/service.d.ts +295 -2
  64. package/lib/service.d.ts.map +1 -1
  65. package/lib/service.js +965 -5
  66. package/lib/service.js.map +1 -1
  67. package/lib/sqlite.d.ts +8 -2
  68. package/lib/sqlite.d.ts.map +1 -1
  69. package/lib/sqlite.js +406 -17
  70. package/lib/sqlite.js.map +1 -1
  71. package/lib/store.d.ts +13 -2
  72. package/lib/store.d.ts.map +1 -1
  73. package/lib/store.js +122 -24
  74. package/lib/store.js.map +1 -1
  75. package/lib/types.d.ts +247 -5
  76. package/lib/types.d.ts.map +1 -1
  77. package/lib/types.js +11 -0
  78. package/lib/types.js.map +1 -1
  79. package/lib/version.d.ts +1 -1
  80. package/lib/version.js +1 -1
  81. package/lib/work-item-owner-proof.d.ts +4 -0
  82. package/lib/work-item-owner-proof.d.ts.map +1 -0
  83. package/lib/work-item-owner-proof.js +53 -0
  84. package/lib/work-item-owner-proof.js.map +1 -0
  85. package/package.json +29 -38
  86. package/lib/benchmark/super-relay.d.ts +0 -26
  87. package/lib/benchmark/super-relay.d.ts.map +0 -1
  88. package/lib/benchmark/super-relay.js +0 -173
  89. package/lib/benchmark/super-relay.js.map +0 -1
@@ -0,0 +1,1044 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { canonicalEvaluationScope, EvaluationStoreError } from './store.js';
3
+ /**
4
+ * WP22 slice 2: bounded, tighten-only attention tuning with exact-version
5
+ * cohort rollback. A cohort is one notification `(lane, sourceId)` inside one
6
+ * Evaluation scope. Versions are immutable and bound to their exact parent.
7
+ *
8
+ * WP22 slice 3 (explicit opt-in): a loosening request is sealed against a
9
+ * hash-partitioned holdout the proposer never reads, replayed independently
10
+ * on a later tick, and adopted only inside an owner one-time grant; outside
11
+ * it a structured owner question is recorded instead. Without the opt-in,
12
+ * loosening is rejected exactly as in slice 2.
13
+ */
14
+ export const ATTENTION_TUNING_PROTOCOL = 'assistant-evaluation/attention-tuning/v1';
15
+ /** Fixed code constants; neither configuration nor a request can widen them. */
16
+ export const attentionTuningBounds = Object.freeze({
17
+ interruptionCostDelta: Object.freeze({ min: 0, max: 100, step: 10 }),
18
+ minimumUtilityDelta: Object.freeze({ min: 0, max: 100, step: 10 }),
19
+ quietExtensionMinutes: Object.freeze({ min: 0, max: 120, step: 30 }),
20
+ });
21
+ /** Fixed single-step order: cheapest reversible lever first, mute last. */
22
+ export const attentionTuningOrder = Object.freeze(['interruptionCostDelta', 'minimumUtilityDelta', 'quietExtensionMinutes', 'muted']);
23
+ export const defaultAttentionTuningParameters = Object.freeze({
24
+ interruptionCostDelta: 0, minimumUtilityDelta: 0, quietExtensionMinutes: 0, muted: false,
25
+ });
26
+ function invalid(message) {
27
+ throw new EvaluationStoreError('invalid-input', `attention tuning: ${message}`);
28
+ }
29
+ function stable(value) {
30
+ if (value === null || typeof value !== 'object')
31
+ return JSON.stringify(value);
32
+ if (Array.isArray(value))
33
+ return `[${value.map(stable).join(',')}]`;
34
+ const record = value;
35
+ return `{${Object.keys(record).filter(key => record[key] !== undefined).sort()
36
+ .map(key => `${JSON.stringify(key)}:${stable(record[key])}`).join(',')}}`;
37
+ }
38
+ function sha256(value) {
39
+ return createHash('sha256').update(stable(value)).digest('hex');
40
+ }
41
+ function deepFreeze(value) {
42
+ if (value !== null && typeof value === 'object') {
43
+ for (const entry of Object.values(value))
44
+ deepFreeze(entry);
45
+ Object.freeze(value);
46
+ }
47
+ return value;
48
+ }
49
+ function identifier(value, label, max) {
50
+ if (typeof value !== 'string' || value.length < 1 || value.length > max || value.trim() !== value || /\p{Cc}/u.test(value)) {
51
+ invalid(`${label} must be a bounded identifier`);
52
+ }
53
+ return value;
54
+ }
55
+ export function normalizeAttentionCohort(input) {
56
+ if (input === null || typeof input !== 'object' || Array.isArray(input))
57
+ invalid('cohort must be an object');
58
+ const record = input;
59
+ if (Object.keys(record).some(key => key !== 'lane' && key !== 'sourceId'))
60
+ invalid('cohort has unknown fields');
61
+ return Object.freeze({ lane: identifier(record.lane, 'cohort.lane', 64), sourceId: identifier(record.sourceId, 'cohort.sourceId', 256) });
62
+ }
63
+ /** Strict shape check only; bounds and step alignment are judged separately so a request can be recorded. */
64
+ export function normalizeAttentionTuningParameters(input) {
65
+ if (input === null || typeof input !== 'object' || Array.isArray(input))
66
+ invalid('parameters must be an object');
67
+ const record = input;
68
+ const keys = Object.keys(record).sort();
69
+ if (stable(keys) !== stable([...attentionTuningOrder].sort()))
70
+ invalid('parameters must name exactly the tunable fields');
71
+ for (const key of ['interruptionCostDelta', 'minimumUtilityDelta', 'quietExtensionMinutes']) {
72
+ if (!Number.isSafeInteger(record[key]))
73
+ invalid(`${key} must be an integer`);
74
+ }
75
+ if (typeof record.muted !== 'boolean')
76
+ invalid('muted must be a boolean');
77
+ return Object.freeze({
78
+ interruptionCostDelta: record.interruptionCostDelta,
79
+ minimumUtilityDelta: record.minimumUtilityDelta,
80
+ quietExtensionMinutes: record.quietExtensionMinutes,
81
+ muted: record.muted,
82
+ });
83
+ }
84
+ /** Clamp every numeric field into its fixed bound (snapped down to the step grid). */
85
+ export function clampAttentionTuning(input) {
86
+ const clamp = (key) => {
87
+ const { min, max, step } = attentionTuningBounds[key];
88
+ const value = Math.min(max, Math.max(min, Math.trunc(input[key])));
89
+ return min + Math.floor((value - min) / step) * step;
90
+ };
91
+ return Object.freeze({
92
+ interruptionCostDelta: clamp('interruptionCostDelta'),
93
+ minimumUtilityDelta: clamp('minimumUtilityDelta'),
94
+ quietExtensionMinutes: clamp('quietExtensionMinutes'),
95
+ muted: input.muted === true,
96
+ });
97
+ }
98
+ export function withinAttentionTuningBounds(input) {
99
+ return stable(clampAttentionTuning(input)) === stable(input);
100
+ }
101
+ /** True when any field would make notifications more frequent than `current`. */
102
+ export function isAttentionLoosening(current, next) {
103
+ return next.interruptionCostDelta < current.interruptionCostDelta
104
+ || next.minimumUtilityDelta < current.minimumUtilityDelta
105
+ || next.quietExtensionMinutes < current.quietExtensionMinutes
106
+ || (current.muted && !next.muted);
107
+ }
108
+ function atLeastRate(numerator, denominator, ratePpm) {
109
+ return BigInt(numerator) * 1000000n >= BigInt(ratePpm) * BigInt(denominator);
110
+ }
111
+ /**
112
+ * Pure tighten-only tuner. With at least `minSample` decided notifications and
113
+ * a negative (rejected + expired-unhandled) rate at or above the threshold, it
114
+ * proposes exactly one step on the first non-maxed parameter in fixed order;
115
+ * mute additionally requires `muteRatePpm`. Never proposes loosening.
116
+ */
117
+ export function proposeAttentionTuning(currentInput, stats, policy) {
118
+ const current = clampAttentionTuning(currentInput);
119
+ if (stats.decided < policy.minSample || stats.decided === 0)
120
+ return deepFreeze({ kind: 'no-op', reason: 'insufficient-sample' });
121
+ if (!atLeastRate(stats.negative, stats.decided, policy.negativeRatePpm))
122
+ return deepFreeze({ kind: 'no-op', reason: 'below-threshold' });
123
+ for (const parameter of attentionTuningOrder) {
124
+ if (parameter === 'muted') {
125
+ if (current.muted || !atLeastRate(stats.negative, stats.decided, policy.muteRatePpm))
126
+ continue;
127
+ return deepFreeze({ kind: 'tighten', change: { parameter, from: false, to: true }, parameters: { ...current, muted: true } });
128
+ }
129
+ const { max, step } = attentionTuningBounds[parameter];
130
+ if (current[parameter] >= max)
131
+ continue;
132
+ const to = Math.min(max, current[parameter] + step);
133
+ return deepFreeze({ kind: 'tighten', change: { parameter, from: current[parameter], to }, parameters: { ...current, [parameter]: to } });
134
+ }
135
+ return deepFreeze({ kind: 'no-op', reason: 'at-bound' });
136
+ }
137
+ /**
138
+ * Regression test for an observing version: the post-adoption negative rate
139
+ * must exceed the basis (parent-era) rate by more than the margin. Cross
140
+ * multiplication keeps it exact; acceptances only lower the rate, so positive
141
+ * evidence can never satisfy it.
142
+ */
143
+ export function attentionRegressed(basis, post, policy) {
144
+ if (post.decided < policy.minSample || post.decided === 0 || basis.decided === 0)
145
+ return false;
146
+ const left = BigInt(post.negative) * BigInt(basis.decided) * 1000000n;
147
+ const right = BigInt(basis.negative) * BigInt(post.decided) * 1000000n + BigInt(policy.regressionMarginPpm) * BigInt(basis.decided) * BigInt(post.decided);
148
+ return left > right;
149
+ }
150
+ /**
151
+ * WP22 slice 3: sealed holdout partition. Membership is a pure function of the
152
+ * notification id under a fixed protocol salt and modulus, so it is
153
+ * reproducible by any auditor and never chosen by the proposer.
154
+ */
155
+ export const ATTENTION_HOLDOUT_PROTOCOL = 'assistant-evaluation/attention-holdout/v1';
156
+ export const attentionHoldoutModulus = 4;
157
+ export const ATTENTION_LOOSENING_PROTOCOL = 'assistant-evaluation/attention-loosening/v1';
158
+ export const ATTENTION_LOOSENING_GRANT_PROTOCOL = 'assistant-evaluation/attention-loosening-grant/v1';
159
+ export const ATTENTION_OWNER_QUESTION_PROTOCOL = 'assistant-evaluation/attention-owner-question/v1';
160
+ export function attentionHoldoutMember(notificationId) {
161
+ const hex = createHash('sha256').update(`${ATTENTION_HOLDOUT_PROTOCOL}\n${notificationId}`).digest('hex');
162
+ return Number.parseInt(hex.slice(0, 8), 16) % attentionHoldoutModulus === 0;
163
+ }
164
+ /** True when `a` suppresses at least everything `b` suppresses (every field at least as tight). */
165
+ export function attentionTighterOrEqual(a, b) {
166
+ return a.interruptionCostDelta >= b.interruptionCostDelta
167
+ && a.minimumUtilityDelta >= b.minimumUtilityDelta
168
+ && a.quietExtensionMinutes >= b.quietExtensionMinutes
169
+ && (a.muted || !b.muted);
170
+ }
171
+ /** One parameter moved toward the default by whole grid steps, or an unmute; anything else is undefined. */
172
+ export function attentionLooseningChange(current, next) {
173
+ const changed = attentionTuningOrder.filter(key => current[key] !== next[key]);
174
+ if (changed.length !== 1)
175
+ return undefined;
176
+ const parameter = changed[0];
177
+ if (parameter === 'muted')
178
+ return current.muted && !next.muted ? Object.freeze({ parameter, from: true, to: false, steps: 1 }) : undefined;
179
+ const delta = current[parameter] - next[parameter];
180
+ const { step } = attentionTuningBounds[parameter];
181
+ if (delta <= 0 || delta % step !== 0)
182
+ return undefined;
183
+ return Object.freeze({ parameter, from: current[parameter], to: next[parameter], steps: delta / step });
184
+ }
185
+ const positiveOutcomes = ['accepted', 'reacted'];
186
+ /**
187
+ * Deterministic counterfactual replay of a loosening on holdout records.
188
+ * Notifications carry no per-message utility, so each is attributed by the
189
+ * era it was sent under: an era at least as tight as a configuration proves
190
+ * that configuration would also have sent it. Passing requires enough
191
+ * discriminating evidence, an extra-interruption rate below the tuning
192
+ * threshold minus the regression margin, a candidate-wide negative rate below
193
+ * the tightening threshold (so it would not be immediately re-tightened), and
194
+ * more owner-valued notifications recovered than extra interruptions added.
195
+ */
196
+ export function replayAttentionLoosening(records, parent, candidate, policy) {
197
+ const tally = () => ({ decided: 0, positive: 0, negative: 0 });
198
+ const shared = tally();
199
+ const discriminating = tally();
200
+ const unattributable = tally();
201
+ for (const record of records) {
202
+ const positive = positiveOutcomes.includes(record.outcome);
203
+ const negative = negativeOutcomes.includes(record.outcome);
204
+ if (!positive && !negative)
205
+ continue;
206
+ const bucket = attentionTighterOrEqual(record.era, parent) ? shared
207
+ : attentionTighterOrEqual(record.era, candidate) ? discriminating : unattributable;
208
+ bucket.decided += 1;
209
+ if (positive)
210
+ bucket.positive += 1;
211
+ else
212
+ bucket.negative += 1;
213
+ }
214
+ const maxDiscriminatingNegativeRatePpm = Math.max(0, policy.negativeRatePpm - policy.regressionMarginPpm);
215
+ const candidateDecided = shared.decided + discriminating.decided;
216
+ const candidateNegative = shared.negative + discriminating.negative;
217
+ const reason = discriminating.decided < policy.minSample || discriminating.decided === 0 ? 'insufficient-holdout'
218
+ : BigInt(discriminating.negative) * 1000000n > BigInt(maxDiscriminatingNegativeRatePpm) * BigInt(discriminating.decided) ? 'extra-interruptions'
219
+ : atLeastRate(candidateNegative, candidateDecided, policy.negativeRatePpm) ? 'candidate-negative-rate'
220
+ : discriminating.positive <= discriminating.negative ? 'no-net-benefit'
221
+ : undefined;
222
+ return deepFreeze({
223
+ protocol: ATTENTION_LOOSENING_PROTOCOL, shared, discriminating, unattributable,
224
+ parent: { falsePositives: shared.negative, misses: discriminating.positive },
225
+ candidate: { falsePositives: candidateNegative, misses: 0 },
226
+ thresholds: { minSample: policy.minSample, maxDiscriminatingNegativeRatePpm, maxCandidateNegativeRatePpm: policy.negativeRatePpm },
227
+ verdict: reason === undefined ? 'pass' : 'fail',
228
+ ...(reason === undefined ? {} : { reason }),
229
+ });
230
+ }
231
+ export function normalizeAttentionLooseningGrant(input) {
232
+ if (input === null || typeof input !== 'object' || Array.isArray(input))
233
+ invalid('grant must be an object');
234
+ const record = input;
235
+ const fields = ['expiresAt', 'grantId', 'lowerBound', 'maxAdoptions', 'maxStepsPerAdoption', 'upperBound'];
236
+ if (stable(Object.keys(record).sort()) !== stable(fields))
237
+ invalid('grant must name exactly the grant fields');
238
+ const grantId = identifier(record.grantId, 'grant.grantId', 128);
239
+ const lowerBound = normalizeAttentionTuningParameters(record.lowerBound);
240
+ const upperBound = normalizeAttentionTuningParameters(record.upperBound);
241
+ if (!withinAttentionTuningBounds(lowerBound) || !withinAttentionTuningBounds(upperBound))
242
+ invalid('grant bounds must lie on the fixed tuning grid');
243
+ if (!attentionTighterOrEqual(upperBound, lowerBound))
244
+ invalid('grant upperBound must be at least as tight as lowerBound');
245
+ const integer = (key, min, max) => {
246
+ const value = record[key];
247
+ if (!Number.isSafeInteger(value) || value < min || value > max)
248
+ invalid(`grant.${key} must be an integer in [${min}, ${max}]`);
249
+ return value;
250
+ };
251
+ return deepFreeze({
252
+ grantId, lowerBound: { ...lowerBound }, upperBound: { ...upperBound },
253
+ // A sealed proposal moves one parameter by whole grid steps; the widest
254
+ // fixed grid (interruptionCostDelta 0..100 step 10) allows 10. Capping
255
+ // grants lower would make an exact one-time grant impossible to state.
256
+ maxStepsPerAdoption: integer('maxStepsPerAdoption', 1, 10),
257
+ maxAdoptions: integer('maxAdoptions', 1, 10),
258
+ expiresAt: integer('expiresAt', 0, Number.MAX_SAFE_INTEGER),
259
+ });
260
+ }
261
+ /** Every way a holdout-accepted loosening falls outside the active grant; empty means adoptable. */
262
+ export function attentionGrantExceeded(grant, used, parent, candidate, steps, now) {
263
+ if (grant === undefined)
264
+ return Object.freeze(['no-grant']);
265
+ const exceeded = [];
266
+ if (now >= grant.expiresAt)
267
+ exceeded.push('grant-expired');
268
+ if (used >= grant.maxAdoptions)
269
+ exceeded.push('grant-exhausted');
270
+ if (!attentionTighterOrEqual(grant.upperBound, parent) || !attentionTighterOrEqual(candidate, grant.lowerBound))
271
+ exceeded.push('outside-bounds');
272
+ if (steps > grant.maxStepsPerAdoption)
273
+ exceeded.push('step-too-large');
274
+ return Object.freeze(exceeded);
275
+ }
276
+ /** Single-step tightening check for an explicit request. */
277
+ function singleStep(current, next) {
278
+ const changed = attentionTuningOrder.filter(key => current[key] !== next[key]);
279
+ if (changed.length !== 1)
280
+ return undefined;
281
+ const parameter = changed[0];
282
+ if (parameter === 'muted')
283
+ return { parameter, from: current.muted, to: next.muted };
284
+ if (next[parameter] - current[parameter] !== attentionTuningBounds[parameter].step)
285
+ return undefined;
286
+ return { parameter, from: current[parameter], to: next[parameter] };
287
+ }
288
+ function versionDigest(input) {
289
+ return sha256({
290
+ protocol: input.protocol, scopeKey: input.scopeKey, cohort: input.cohort, version: input.version,
291
+ parentVersion: input.parentVersion, parentDigest: input.parentDigest, parameters: input.parameters,
292
+ basis: input.basis, change: input.change, origin: input.origin,
293
+ });
294
+ }
295
+ const negativeOutcomes = ['rejected', 'expired-unhandled'];
296
+ /**
297
+ * Cohort filter: a linked-alias fallback report reached the owner on another
298
+ * channel, so its outcome says nothing about the origin cohort's parameters.
299
+ * Stats, holdout seals/replays and cohort discovery all use this filter;
300
+ * route-free (pre-existing) records count as origin.
301
+ */
302
+ const originRoute = `coalesce(json_extract(r.record_json, '$.route.kind'), 'origin')='origin'`;
303
+ /**
304
+ * Durable ledger over the same Evaluation database as the notification canon.
305
+ * All decisions recompute inside BEGIN IMMEDIATE so concurrent ticks or a
306
+ * restart mid-observation converge on one head.
307
+ */
308
+ export class AttentionTuningLedger {
309
+ #database;
310
+ #now;
311
+ #questions;
312
+ constructor(database, now, questions) {
313
+ this.#database = database;
314
+ this.#now = now;
315
+ this.#questions = questions;
316
+ }
317
+ #transaction(fn) {
318
+ this.#database.exec('BEGIN IMMEDIATE');
319
+ try {
320
+ const value = fn();
321
+ this.#database.exec('COMMIT');
322
+ return value;
323
+ }
324
+ catch (error) {
325
+ this.#database.exec('ROLLBACK');
326
+ throw error;
327
+ }
328
+ }
329
+ /** Current-head (id, outcome, notifiedAt) rows for one cohort, notified in [from, to), in id order. */
330
+ #records(scopeKey, cohort, fromNotifiedAt, toNotifiedAt) {
331
+ return this.#database.prepare(`SELECT h.notification_id AS notification_id, h.outcome AS outcome, h.occurred_at AS occurred_at
332
+ FROM evaluation_notification_outcome_heads h
333
+ JOIN evaluation_notification_outcome_revisions r ON r.notification_id=h.notification_id AND r.version=h.version
334
+ WHERE h.scope_key=? AND h.occurred_at>=? AND h.occurred_at<?
335
+ AND json_extract(r.record_json, '$.lane')=? AND json_extract(r.record_json, '$.sourceId')=?
336
+ AND ${originRoute}
337
+ ORDER BY h.notification_id`).all(scopeKey, fromNotifiedAt, toNotifiedAt, cohort.lane, cohort.sourceId);
338
+ }
339
+ /** Holdout membership for a window: ids only. Outcomes are deliberately not part of the seal. */
340
+ #holdout(scopeKey, cohort, fromNotifiedAt, toNotifiedAt) {
341
+ const ids = this.#records(scopeKey, cohort, fromNotifiedAt, toNotifiedAt).map(row => row.notification_id).filter(attentionHoldoutMember);
342
+ const digest = sha256({ protocol: ATTENTION_HOLDOUT_PROTOCOL, modulus: attentionHoldoutModulus, scopeKey, cohort: { lane: cohort.lane, sourceId: cohort.sourceId },
343
+ fromNotifiedAt, toNotifiedAt, ids });
344
+ return deepFreeze({ ids, seal: { protocol: ATTENTION_HOLDOUT_PROTOCOL, modulus: attentionHoldoutModulus, count: ids.length, digest } });
345
+ }
346
+ /**
347
+ * Current-head outcome counts for one cohort, notified in [from, to).
348
+ * `training` excludes the sealed holdout partition; every proposer uses it
349
+ * once the slice-3 opt-in is on, so no proposal is fitted to holdout data.
350
+ */
351
+ stats(scopeKey, cohort, fromNotifiedAt, toNotifiedAt, partition = 'all') {
352
+ if (partition === 'training') {
353
+ const counts = new Map();
354
+ for (const row of this.#records(scopeKey, cohort, fromNotifiedAt, toNotifiedAt)) {
355
+ if (!attentionHoldoutMember(row.notification_id))
356
+ counts.set(row.outcome, (counts.get(row.outcome) ?? 0) + 1);
357
+ }
358
+ return this.#summarize(fromNotifiedAt, toNotifiedAt, [...counts].map(([outcome, count]) => ({ outcome, count })));
359
+ }
360
+ const rows = this.#database.prepare(`SELECT h.outcome AS outcome, COUNT(*) AS count
361
+ FROM evaluation_notification_outcome_heads h
362
+ JOIN evaluation_notification_outcome_revisions r ON r.notification_id=h.notification_id AND r.version=h.version
363
+ WHERE h.scope_key=? AND h.occurred_at>=? AND h.occurred_at<?
364
+ AND json_extract(r.record_json, '$.lane')=? AND json_extract(r.record_json, '$.sourceId')=?
365
+ AND ${originRoute}
366
+ GROUP BY h.outcome`).all(scopeKey, fromNotifiedAt, toNotifiedAt, cohort.lane, cohort.sourceId);
367
+ return this.#summarize(fromNotifiedAt, toNotifiedAt, rows);
368
+ }
369
+ #summarize(fromNotifiedAt, toNotifiedAt, rows) {
370
+ const count = (outcome) => rows.find(row => row.outcome === outcome)?.count ?? 0;
371
+ const accepted = count('accepted');
372
+ const reacted = count('reacted');
373
+ const rejected = count('rejected');
374
+ const expiredUnhandled = count('expired-unhandled');
375
+ return Object.freeze({
376
+ fromNotifiedAt, toNotifiedAt,
377
+ total: rows.reduce((sum, row) => sum + row.count, 0),
378
+ accepted, reacted, rejected, expiredUnhandled,
379
+ decided: accepted + reacted + rejected + expiredUnhandled,
380
+ negative: negativeOutcomes.reduce((sum, outcome) => sum + count(outcome), 0),
381
+ });
382
+ }
383
+ /** Scopes that have at least one notification in the cohort, plus scopes with a tuning head. */
384
+ scopes(cohort) {
385
+ const rows = this.#database.prepare(`SELECT DISTINCT json_extract(r.record_json, '$.scope.workspace') AS workspace,
386
+ json_extract(r.record_json, '$.scope.preset') AS preset
387
+ FROM evaluation_notification_outcome_heads h
388
+ JOIN evaluation_notification_outcome_revisions r ON r.notification_id=h.notification_id AND r.version=h.version
389
+ WHERE json_extract(r.record_json, '$.lane')=? AND json_extract(r.record_json, '$.sourceId')=? AND ${originRoute}
390
+ UNION SELECT workspace, preset FROM evaluation_attention_tuning_heads WHERE lane=? AND source_id=?
391
+ ORDER BY workspace, preset`).all(cohort.lane, cohort.sourceId, cohort.lane, cohort.sourceId);
392
+ return Object.freeze(rows.map(row => Object.freeze({ workspace: row.workspace, preset: row.preset })));
393
+ }
394
+ #scopeWatermark(scopeKey) {
395
+ return this.#database.prepare('SELECT watermark FROM evaluation_notification_watermarks WHERE scope_key=?')
396
+ .get(scopeKey)?.watermark ?? 0;
397
+ }
398
+ #head(scopeKey, cohort) {
399
+ return this.#database.prepare('SELECT * FROM evaluation_attention_tuning_heads WHERE scope_key=? AND lane=? AND source_id=?')
400
+ .get(scopeKey, cohort.lane, cohort.sourceId);
401
+ }
402
+ #row(scopeKey, cohort, version) {
403
+ return this.#database.prepare('SELECT * FROM evaluation_attention_tuning_versions WHERE scope_key=? AND lane=? AND source_id=? AND version=?')
404
+ .get(scopeKey, cohort.lane, cohort.sourceId, version);
405
+ }
406
+ /** Parse and digest-verify one stored version; undefined when unreadable or tampered. */
407
+ #verified(scopeKey, cohort, version) {
408
+ const row = this.#row(scopeKey, cohort, version);
409
+ if (row === undefined)
410
+ return undefined;
411
+ try {
412
+ const parentDigest = row.parent_version === 0 ? null : this.#row(scopeKey, cohort, row.parent_version)?.digest ?? null;
413
+ if (row.parent_version !== 0 && parentDigest === null)
414
+ return undefined;
415
+ const parameters = normalizeAttentionTuningParameters(JSON.parse(row.parameters_json));
416
+ if (!withinAttentionTuningBounds(parameters))
417
+ return undefined;
418
+ const body = {
419
+ protocol: ATTENTION_TUNING_PROTOCOL, scopeKey, cohort: { lane: cohort.lane, sourceId: cohort.sourceId }, version: row.version,
420
+ parentVersion: row.parent_version, parentDigest, parameters,
421
+ basis: JSON.parse(row.basis_json),
422
+ change: JSON.parse(row.change_json), origin: row.origin,
423
+ };
424
+ if (versionDigest(body) !== row.digest)
425
+ return undefined;
426
+ return deepFreeze({ ...body, digest: row.digest, createdAt: row.created_at });
427
+ }
428
+ catch {
429
+ return undefined;
430
+ }
431
+ }
432
+ #versionNumbers(scopeKey, cohort) {
433
+ return this.#database.prepare('SELECT version FROM evaluation_attention_tuning_versions WHERE scope_key=? AND lane=? AND source_id=? ORDER BY version')
434
+ .all(scopeKey, cohort.lane, cohort.sourceId).map(row => row.version);
435
+ }
436
+ /**
437
+ * Active parameters for a consumer. A version that cannot be read or fails
438
+ * digest verification falls back along its recorded parent chain, then to
439
+ * the built-in default; a consumer never sees an unverified overlay.
440
+ */
441
+ current(scopeInput, cohortInput) {
442
+ const { scopeKey } = canonicalEvaluationScope(scopeInput);
443
+ const cohort = normalizeAttentionCohort(cohortInput);
444
+ return this.#current(scopeKey, cohort);
445
+ }
446
+ #current(scopeKey, cohort) {
447
+ const active = this.#head(scopeKey, cohort)?.active_version ?? 0;
448
+ let version = active;
449
+ let fallback = false;
450
+ const seen = new Set();
451
+ while (version > 0 && !seen.has(version)) {
452
+ seen.add(version);
453
+ const verified = this.#verified(scopeKey, cohort, version);
454
+ if (verified !== undefined) {
455
+ return deepFreeze({ scopeKey, cohort: { ...cohort }, version, digest: verified.digest, parameters: { ...verified.parameters }, source: fallback ? 'parent-fallback' : 'active' });
456
+ }
457
+ fallback = true;
458
+ const parent = this.#database.prepare('SELECT parent_version FROM evaluation_attention_tuning_versions WHERE scope_key=? AND lane=? AND source_id=? AND version=?')
459
+ .get(scopeKey, cohort.lane, cohort.sourceId, version)?.parent_version;
460
+ version = Number.isSafeInteger(parent) && parent >= 0 && parent < version ? parent : 0;
461
+ }
462
+ return deepFreeze({ scopeKey, cohort: { ...cohort }, version: 0, digest: null, parameters: { ...defaultAttentionTuningParameters }, source: active === 0 ? 'active' : 'default' });
463
+ }
464
+ status(scopeInput, cohortInput, eventLimit = 50) {
465
+ const { scopeKey } = canonicalEvaluationScope(scopeInput);
466
+ const cohort = normalizeAttentionCohort(cohortInput);
467
+ const head = this.#head(scopeKey, cohort);
468
+ const versions = this.#versionNumbers(scopeKey, cohort)
469
+ .map(version => this.#verified(scopeKey, cohort, version)).filter(value => value !== undefined);
470
+ const events = this.#database.prepare(`SELECT sequence, kind, from_version, to_version, reason, detail_json, occurred_at
471
+ FROM evaluation_attention_tuning_events WHERE scope_key=? AND lane=? AND source_id=? ORDER BY sequence DESC LIMIT ?`)
472
+ .all(scopeKey, cohort.lane, cohort.sourceId, eventLimit).reverse().map(row => ({
473
+ sequence: row.sequence, kind: row.kind, fromVersion: row.from_version, toVersion: row.to_version,
474
+ reason: row.reason, detail: JSON.parse(row.detail_json), occurredAt: row.occurred_at,
475
+ }));
476
+ return deepFreeze({
477
+ current: this.#current(scopeKey, cohort),
478
+ state: head?.state ?? 'idle',
479
+ ...(head?.observe_from == null ? {} : { observeFrom: head.observe_from }),
480
+ ...(head?.observe_until == null ? {} : { observeUntil: head.observe_until }),
481
+ versions, events,
482
+ });
483
+ }
484
+ #event(scopeKey, cohort, kind, fromVersion, toVersion, reason, detail, idempotencyKey, now) {
485
+ this.#database.prepare(`INSERT INTO evaluation_attention_tuning_events
486
+ (scope_key, lane, source_id, kind, from_version, to_version, reason, detail_json, idempotency_key, occurred_at) VALUES (?,?,?,?,?,?,?,?,?,?)
487
+ ON CONFLICT(idempotency_key) DO NOTHING`)
488
+ .run(scopeKey, cohort.lane, cohort.sourceId, kind, fromVersion, toVersion, reason, stable(detail), idempotencyKey, now);
489
+ }
490
+ #adopt(scope, scopeKey, cohort, head, current, proposal, stats, origin, observationMs, now, loosening) {
491
+ // Parent is the verified version the consumer is actually running.
492
+ const parentVersion = current.version;
493
+ const scopeWatermark = this.#scopeWatermark(scopeKey);
494
+ const idempotencyKey = `adopt:${sha256([scopeKey, cohort.lane, cohort.sourceId, parentVersion, current.digest, scopeWatermark, proposal.change, origin,
495
+ ...(loosening === undefined ? [] : [loosening.proposalId])])}`;
496
+ const existing = this.#database.prepare('SELECT version FROM evaluation_attention_tuning_versions WHERE idempotency_key=?')
497
+ .get(idempotencyKey);
498
+ if (existing !== undefined)
499
+ return existing.version;
500
+ const version = (head?.latest_version ?? 0) + 1;
501
+ const body = {
502
+ protocol: ATTENTION_TUNING_PROTOCOL, scopeKey, cohort: { lane: cohort.lane, sourceId: cohort.sourceId }, version,
503
+ parentVersion, parentDigest: current.digest, parameters: proposal.parameters,
504
+ basis: { stats, scopeWatermark }, change: proposal.change, origin,
505
+ };
506
+ const digest = versionDigest(body);
507
+ this.#database.prepare(`INSERT INTO evaluation_attention_tuning_versions
508
+ (scope_key, lane, source_id, version, parent_version, parameters_json, basis_json, change_json, origin, digest, idempotency_key, created_at)
509
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`).run(scopeKey, cohort.lane, cohort.sourceId, version, parentVersion, stable(body.parameters), stable(body.basis), stable(body.change), origin, digest, idempotencyKey, now);
510
+ const observeUntil = now + observationMs;
511
+ if (head === undefined) {
512
+ this.#database.prepare(`INSERT INTO evaluation_attention_tuning_heads
513
+ (scope_key, lane, source_id, workspace, preset, active_version, latest_version, state, observing_version, observe_from, observe_until, changed_at)
514
+ VALUES (?,?,?,?,?,?,?, 'observing', ?,?,?,?)`).run(scopeKey, cohort.lane, cohort.sourceId, scope.workspace, scope.preset, version, version, version, now, observeUntil, now);
515
+ }
516
+ else {
517
+ this.#database.prepare(`UPDATE evaluation_attention_tuning_heads SET active_version=?, latest_version=?, state='observing',
518
+ observing_version=?, observe_from=?, observe_until=?, changed_at=? WHERE scope_key=? AND lane=? AND source_id=?`)
519
+ .run(version, version, version, now, observeUntil, now, scopeKey, cohort.lane, cohort.sourceId);
520
+ }
521
+ this.#event(scopeKey, cohort, 'adopted', parentVersion, version, loosening !== undefined ? 'holdout-accepted' : origin === 'auto' ? 'negative-rate-threshold' : 'owner-request', { digest, change: proposal.change, stats, scopeWatermark, ...loosening }, idempotencyKey, now);
522
+ return version;
523
+ }
524
+ /**
525
+ * One deterministic step for one cohort: monitor an observing version
526
+ * (rollback to its exact parent on regression, settle at window end), or
527
+ * when idle propose and adopt a single bounded tightening step.
528
+ */
529
+ tick(scopeInput, cohortInput, policy, options) {
530
+ const { scope, scopeKey } = canonicalEvaluationScope(scopeInput);
531
+ const cohort = normalizeAttentionCohort(cohortInput);
532
+ return this.#transaction(() => {
533
+ const now = this.#now();
534
+ const head = this.#head(scopeKey, cohort);
535
+ if (head?.state === 'observing') {
536
+ const observed = head.observing_version;
537
+ const version = this.#verified(scopeKey, cohort, observed);
538
+ const post = this.stats(scopeKey, cohort, head.observe_from, now + 1);
539
+ // An unverifiable observing version is reverted exactly like a regression.
540
+ if (version === undefined || attentionRegressed(version.basis.stats, post, policy)) {
541
+ const parent = this.#database.prepare('SELECT parent_version FROM evaluation_attention_tuning_versions WHERE scope_key=? AND lane=? AND source_id=? AND version=?')
542
+ .get(scopeKey, cohort.lane, cohort.sourceId, observed)?.parent_version ?? 0;
543
+ this.#database.prepare(`UPDATE evaluation_attention_tuning_heads SET active_version=?, state='idle', observing_version=NULL,
544
+ observe_from=NULL, observe_until=NULL, changed_at=? WHERE scope_key=? AND lane=? AND source_id=?`)
545
+ .run(parent, now, scopeKey, cohort.lane, cohort.sourceId);
546
+ this.#event(scopeKey, cohort, 'rolled-back', observed, parent, version === undefined ? 'version-unverifiable' : 'negative-rate-regressed', { basis: version?.basis.stats ?? null, post }, `rollback:${sha256([scopeKey, cohort.lane, cohort.sourceId, observed, parent])}`, now);
547
+ return Object.freeze({ action: 'rolled-back', fromVersion: observed, toVersion: parent });
548
+ }
549
+ if (now >= head.observe_until) {
550
+ this.#database.prepare(`UPDATE evaluation_attention_tuning_heads SET state='idle', observing_version=NULL,
551
+ observe_from=NULL, observe_until=NULL, changed_at=? WHERE scope_key=? AND lane=? AND source_id=?`)
552
+ .run(now, scopeKey, cohort.lane, cohort.sourceId);
553
+ this.#event(scopeKey, cohort, 'settled', observed, observed, 'observation-complete', { post }, `settle:${sha256([scopeKey, cohort.lane, cohort.sourceId, observed, head.observe_from])}`, now);
554
+ return Object.freeze({ action: 'settled', version: observed });
555
+ }
556
+ return Object.freeze({ action: 'observing', version: observed });
557
+ }
558
+ if (options.loosening !== undefined) {
559
+ // A sealed loosening proposal is judged before any new tightening so
560
+ // its parent cannot move underneath it.
561
+ const pending = this.#pendingProposal(scopeKey, cohort);
562
+ if (pending !== undefined)
563
+ return this.#decideLoosening(scope, scopeKey, cohort, head, pending, policy, options.loosening, options.observationMs, now);
564
+ }
565
+ const current = this.#current(scopeKey, cohort);
566
+ // Evidence is only counted since the last change, so an adopted or
567
+ // rolled-back version is never re-judged on its predecessor's sample.
568
+ const from = Math.max(0, head?.changed_at ?? 0, now - options.evaluationWindowMs);
569
+ const stats = this.stats(scopeKey, cohort, from, now + 1, options.loosening === undefined ? 'all' : 'training');
570
+ const proposal = proposeAttentionTuning(current.parameters, stats, policy);
571
+ if (proposal.kind === 'no-op')
572
+ return Object.freeze({ action: 'no-op', reason: proposal.reason });
573
+ const version = this.#adopt(scope, scopeKey, cohort, head, current, proposal, stats, 'auto', options.observationMs, now);
574
+ return Object.freeze({ action: 'adopted', version, change: proposal.change });
575
+ });
576
+ }
577
+ /**
578
+ * Explicit parameter request. Any loosening field is rejected with
579
+ * `loosening-requires-holdout` and recorded; so is every other rejection.
580
+ * A single in-bounds tightening step is adopted with an observation window.
581
+ */
582
+ request(scopeInput, cohortInput, parametersInput, options) {
583
+ const { scope, scopeKey } = canonicalEvaluationScope(scopeInput);
584
+ const cohort = normalizeAttentionCohort(cohortInput);
585
+ const requested = normalizeAttentionTuningParameters(parametersInput);
586
+ return this.#transaction(() => {
587
+ const now = this.#now();
588
+ const head = this.#head(scopeKey, cohort);
589
+ const current = this.#current(scopeKey, cohort);
590
+ if (options.loosening !== undefined && isAttentionLoosening(current.parameters, requested)) {
591
+ return this.#proposeLoosening(scopeKey, cohort, head, current, requested, options.enabled, options.loosening, now);
592
+ }
593
+ const change = singleStep(current.parameters, requested);
594
+ const reason = isAttentionLoosening(current.parameters, requested) ? 'loosening-requires-holdout'
595
+ : !withinAttentionTuningBounds(requested) ? 'out-of-bounds'
596
+ : stable(requested) === stable(current.parameters) ? 'no-change'
597
+ : change === undefined ? 'not-single-step'
598
+ : !options.enabled ? 'auto-adoption-disabled'
599
+ : head?.state === 'observing' ? 'observation-in-progress'
600
+ : undefined;
601
+ if (reason !== undefined || change === undefined) {
602
+ const code = reason ?? 'not-single-step';
603
+ this.#event(scopeKey, cohort, 'request-rejected', current.version, current.version, code, { current: current.parameters, requested }, null, now);
604
+ return Object.freeze({ status: 'rejected', reason: code });
605
+ }
606
+ const stats = this.stats(scopeKey, cohort, Math.max(0, head?.changed_at ?? 0, now - options.evaluationWindowMs), now + 1, options.loosening === undefined ? 'all' : 'training');
607
+ const version = this.#adopt(scope, scopeKey, cohort, head, current, { change, parameters: requested }, stats, 'request', options.observationMs, now);
608
+ return deepFreeze({ status: 'adopted', version, change });
609
+ });
610
+ }
611
+ /**
612
+ * Slice 3 loosening request: validate, then seal a proposal that names the
613
+ * holdout membership by digest only. Nothing is adopted here; a later tick
614
+ * replays the holdout independently and checks the owner grant.
615
+ */
616
+ #proposeLoosening(scopeKey, cohort, head, current, requested, enabled, loosening, now) {
617
+ const change = attentionLooseningChange(current.parameters, requested);
618
+ const reason = !withinAttentionTuningBounds(requested) ? 'out-of-bounds'
619
+ : change === undefined ? 'not-single-parameter'
620
+ : !enabled ? 'auto-adoption-disabled'
621
+ : head?.state === 'observing' ? 'observation-in-progress'
622
+ : this.#pendingProposal(scopeKey, cohort) !== undefined ? 'proposal-pending'
623
+ : this.#openQuestion(scopeKey, cohort) ? 'owner-question-pending'
624
+ : undefined;
625
+ if (reason !== undefined || change === undefined) {
626
+ const code = reason ?? 'not-single-parameter';
627
+ this.#event(scopeKey, cohort, 'request-rejected', current.version, current.version, code, { current: current.parameters, requested }, null, now);
628
+ return Object.freeze({ status: 'rejected', reason: code });
629
+ }
630
+ const window = { fromNotifiedAt: Math.max(0, now - loosening.replayWindowMs), toNotifiedAt: now + 1 };
631
+ const holdout = this.#holdout(scopeKey, cohort, window.fromNotifiedAt, window.toNotifiedAt);
632
+ const body = {
633
+ protocol: ATTENTION_LOOSENING_PROTOCOL, scopeKey, cohort: { lane: cohort.lane, sourceId: cohort.sourceId },
634
+ ordinal: this.#database.prepare(`SELECT COUNT(*) AS count FROM evaluation_attention_loosening_proposals
635
+ WHERE scope_key=? AND lane=? AND source_id=?`).get(scopeKey, cohort.lane, cohort.sourceId).count + 1,
636
+ parentVersion: current.version, parentDigest: current.digest, parent: { ...current.parameters }, candidate: { ...requested },
637
+ change: { parameter: change.parameter, from: change.from, to: change.to }, steps: change.steps, window,
638
+ holdout: holdout.seal,
639
+ basis: { stats: this.stats(scopeKey, cohort, window.fromNotifiedAt, window.toNotifiedAt, 'training'), scopeWatermark: this.#scopeWatermark(scopeKey) },
640
+ createdAt: now,
641
+ };
642
+ const proposalId = sha256(body);
643
+ this.#database.prepare(`INSERT INTO evaluation_attention_loosening_proposals
644
+ (proposal_id, scope_key, lane, source_id, parent_version, proposal_json, holdout_digest, created_at) VALUES (?,?,?,?,?,?,?,?)`)
645
+ .run(proposalId, scopeKey, cohort.lane, cohort.sourceId, current.version, stable(body), holdout.seal.digest, now);
646
+ return deepFreeze({ status: 'proposed', proposalId, holdout: { ...holdout.seal } });
647
+ }
648
+ #pendingProposal(scopeKey, cohort) {
649
+ return this.#database.prepare(`SELECT p.* FROM evaluation_attention_loosening_proposals p
650
+ LEFT JOIN evaluation_attention_loosening_decisions d ON d.proposal_id=p.proposal_id
651
+ WHERE p.scope_key=? AND p.lane=? AND p.source_id=? AND d.proposal_id IS NULL
652
+ ORDER BY p.created_at, p.rowid LIMIT 1`).get(scopeKey, cohort.lane, cohort.sourceId);
653
+ }
654
+ /**
655
+ * A recorded owner reply closes the question even though the grant row it
656
+ * was asked under is unchanged; only a different grant or a reply closes
657
+ * it, and the latest question stays open until then.
658
+ */
659
+ #openQuestion(scopeKey, cohort) {
660
+ const question = this.#database.prepare(`SELECT question_id, question_json FROM evaluation_attention_owner_questions
661
+ WHERE scope_key=? AND lane=? AND source_id=? ORDER BY created_at DESC, rowid DESC LIMIT 1`)
662
+ .get(scopeKey, cohort.lane, cohort.sourceId);
663
+ if (question === undefined)
664
+ return false;
665
+ let parsed;
666
+ try {
667
+ parsed = JSON.parse(question.question_json);
668
+ }
669
+ catch {
670
+ return true;
671
+ }
672
+ return parsed.grantId === this.#latestGrantId(scopeKey, cohort) && !this.#replied(question.question_id);
673
+ }
674
+ /** True once any reply (authorize or decline) is recorded for this exact question. */
675
+ #replied(questionId) {
676
+ return this.#database.prepare('SELECT 1 AS present FROM evaluation_attention_owner_question_replies WHERE question_id=?')
677
+ .get(questionId) !== undefined;
678
+ }
679
+ /**
680
+ * WP22 slice 4: the latest owner question of a cohort, verified against its
681
+ * content id, and whether it is still open (same rule as `#openQuestion`,
682
+ * including the slice-5 rule that any recorded reply closes it). Older
683
+ * questions are always closed. A row whose body no longer hashes to its id
684
+ * is reported as `unverifiable` and must never be delivered.
685
+ */
686
+ latestOwnerQuestion(scopeInput, cohortInput) {
687
+ const { scopeKey } = canonicalEvaluationScope(scopeInput);
688
+ const cohort = normalizeAttentionCohort(cohortInput);
689
+ const row = this.#database.prepare(`SELECT question_id, question_json FROM evaluation_attention_owner_questions
690
+ WHERE scope_key=? AND lane=? AND source_id=? ORDER BY created_at DESC, rowid DESC LIMIT 1`)
691
+ .get(scopeKey, cohort.lane, cohort.sourceId);
692
+ if (row === undefined)
693
+ return undefined;
694
+ let question;
695
+ try {
696
+ question = JSON.parse(row.question_json);
697
+ }
698
+ catch {
699
+ return 'unverifiable';
700
+ }
701
+ if (sha256(question) !== row.question_id || question.protocol !== ATTENTION_OWNER_QUESTION_PROTOCOL
702
+ || question.scopeKey !== scopeKey || question.cohort?.lane !== cohort.lane || question.cohort?.sourceId !== cohort.sourceId)
703
+ return 'unverifiable';
704
+ return deepFreeze({ questionId: row.question_id, question,
705
+ open: question.grantId === this.#latestGrantId(scopeKey, cohort) && !this.#replied(row.question_id) });
706
+ }
707
+ #latestGrantId(scopeKey, cohort) {
708
+ return this.#database.prepare(`SELECT grant_id FROM evaluation_attention_loosening_grants
709
+ WHERE scope_key=? AND lane=? AND source_id=? ORDER BY created_at DESC, rowid DESC LIMIT 1`)
710
+ .get(scopeKey, cohort.lane, cohort.sourceId)?.grant_id ?? null;
711
+ }
712
+ /**
713
+ * Slice 5 evidence-backed apply path for one exact owner reply. Inside one
714
+ * transaction it verifies the cohort's latest question against its content
715
+ * id, records the reply, and then:
716
+ * - `decline` writes the proposal's one terminal `owner-declined` decision;
717
+ * - `authorize-once` creates a bounded one-time grant whose bounds are
718
+ * exactly the questioned proposal's parent/candidate, charged once, and
719
+ * leaves adoption to the next tuning tick's independent holdout replay;
720
+ * - a proposal that already has a decision (a question recorded before the
721
+ * reply loop was enabled) returns `proposal-terminal` and changes nothing.
722
+ * Record-only callers keep using `AttentionOwnerQuestionLedger.recordReply`.
723
+ */
724
+ applyOwnerQuestionReply(scopeInput, cohortInput, questionIdInput, choiceInput, options) {
725
+ const { scope, scopeKey } = canonicalEvaluationScope(scopeInput);
726
+ const cohort = normalizeAttentionCohort(cohortInput);
727
+ if (typeof questionIdInput !== 'string' || !/^[a-f0-9]{64}$/u.test(questionIdInput))
728
+ invalid('question id is not recognised');
729
+ const choice = choiceInput === 'authorize-once' || choiceInput === 'decline'
730
+ ? choiceInput : undefined;
731
+ if (choice === undefined)
732
+ invalid('choice is not recognised');
733
+ if (!Number.isSafeInteger(options.noticeTtlMs) || options.noticeTtlMs < 1)
734
+ invalid('owner question notice TTL is invalid');
735
+ const questions = this.#questions;
736
+ if (questions === undefined)
737
+ invalid('owner question ledger is unavailable');
738
+ return this.#transaction(() => {
739
+ const now = this.#now();
740
+ const latest = this.latestOwnerQuestion(scope, cohort);
741
+ if (latest === undefined || latest === 'unverifiable' || latest.questionId !== questionIdInput) {
742
+ return Object.freeze({ status: 'rejected', reason: 'unknown-question' });
743
+ }
744
+ const question = latest.question;
745
+ const questionId = latest.questionId;
746
+ const expired = now >= question.createdAt + options.noticeTtlMs;
747
+ const reply = questions.recordReplyInTransaction(scope, cohort, questionId, choice, latest.open, expired);
748
+ if (reply.status === 'rejected')
749
+ return reply;
750
+ const decided = this.#database.prepare('SELECT 1 AS present FROM evaluation_attention_loosening_decisions WHERE proposal_id=?')
751
+ .get(question.proposalId) !== undefined;
752
+ if (decided) {
753
+ return Object.freeze({ status: 'applied', questionId: questionId, choice,
754
+ proposalId: question.proposalId, grantId: null, adoption: 'proposal-terminal' });
755
+ }
756
+ if (choice === 'decline') {
757
+ const version = this.#current(scopeKey, cohort).version;
758
+ this.#decision(scopeKey, cohort, question.proposalId, 'rejected', 'owner-declined', null, null, question.replay, now);
759
+ this.#event(scopeKey, cohort, 'request-rejected', version, version, 'owner-declined', { proposalId: question.proposalId, questionId: questionId, choice }, `owner-question:${questionId}`, now);
760
+ return Object.freeze({ status: 'applied', questionId: questionId, choice,
761
+ proposalId: question.proposalId, grantId: null, adoption: 'declined' });
762
+ }
763
+ // Exactly the questioned proposal: parent as the tightest start, its
764
+ // candidate as the loosest end, one adoption, the proposal's step size.
765
+ const grant = normalizeAttentionLooseningGrant({
766
+ grantId: `owner-question:${questionId}`,
767
+ lowerBound: { ...question.candidate }, upperBound: { ...question.parent },
768
+ maxStepsPerAdoption: question.steps, maxAdoptions: 1, expiresAt: now + options.noticeTtlMs,
769
+ });
770
+ const digest = this.#grantDigest(scopeKey, cohort, grant);
771
+ const existing = this.#database.prepare(`SELECT digest FROM evaluation_attention_loosening_grants
772
+ WHERE scope_key=? AND lane=? AND source_id=? AND grant_id=?`)
773
+ .get(scopeKey, cohort.lane, cohort.sourceId, grant.grantId);
774
+ if (existing === undefined) {
775
+ this.#database.prepare(`INSERT INTO evaluation_attention_loosening_grants
776
+ (scope_key, lane, source_id, grant_id, grant_json, digest, created_at) VALUES (?,?,?,?,?,?,?)`)
777
+ .run(scopeKey, cohort.lane, cohort.sourceId, grant.grantId, stable(grant), digest, now);
778
+ }
779
+ else if (existing.digest !== digest) {
780
+ // Rolls the recorded reply back with this transaction; never leaves an
781
+ // authorization pointing at a grant id it did not create.
782
+ invalid('owner question grant conflicts with an existing grant');
783
+ }
784
+ return Object.freeze({ status: 'applied', questionId: questionId, choice,
785
+ proposalId: question.proposalId, grantId: grant.grantId, adoption: 'deferred' });
786
+ });
787
+ }
788
+ #verifiedProposal(row, scopeKey, cohort) {
789
+ try {
790
+ const body = JSON.parse(row.proposal_json);
791
+ if (sha256(body) !== row.proposal_id || body.protocol !== ATTENTION_LOOSENING_PROTOCOL || body.scopeKey !== scopeKey
792
+ || body.cohort.lane !== cohort.lane || body.cohort.sourceId !== cohort.sourceId || body.parentVersion !== row.parent_version
793
+ || body.holdout.digest !== row.holdout_digest || body.holdout.protocol !== ATTENTION_HOLDOUT_PROTOCOL || body.holdout.modulus !== attentionHoldoutModulus)
794
+ return undefined;
795
+ const parent = normalizeAttentionTuningParameters(body.parent);
796
+ const candidate = normalizeAttentionTuningParameters(body.candidate);
797
+ const change = attentionLooseningChange(parent, candidate);
798
+ if (!withinAttentionTuningBounds(parent) || !withinAttentionTuningBounds(candidate) || change === undefined || change.steps !== body.steps)
799
+ return undefined;
800
+ return deepFreeze(body);
801
+ }
802
+ catch {
803
+ return undefined;
804
+ }
805
+ }
806
+ #grantDigest(scopeKey, cohort, grant) {
807
+ return sha256({ protocol: ATTENTION_LOOSENING_GRANT_PROTOCOL, scopeKey, cohort: { lane: cohort.lane, sourceId: cohort.sourceId }, grant });
808
+ }
809
+ /** Latest grant for the cohort; `unverifiable` when its stored body fails the digest. */
810
+ #activeGrant(scopeKey, cohort) {
811
+ const row = this.#database.prepare(`SELECT grant_id, grant_json, digest, created_at FROM evaluation_attention_loosening_grants
812
+ WHERE scope_key=? AND lane=? AND source_id=? ORDER BY created_at DESC, rowid DESC LIMIT 1`)
813
+ .get(scopeKey, cohort.lane, cohort.sourceId);
814
+ if (row === undefined)
815
+ return undefined;
816
+ try {
817
+ const grant = normalizeAttentionLooseningGrant(JSON.parse(row.grant_json));
818
+ if (grant.grantId !== row.grant_id || this.#grantDigest(scopeKey, cohort, grant) !== row.digest)
819
+ return 'unverifiable';
820
+ return Object.freeze({ grant, digest: row.digest, createdAt: row.created_at });
821
+ }
822
+ catch {
823
+ return 'unverifiable';
824
+ }
825
+ }
826
+ /** Adoptions charged to a grant. Decisions are immutable, so a rollback never refunds quota. */
827
+ #grantUsed(scopeKey, cohort, grantId) {
828
+ return this.#database.prepare(`SELECT COUNT(*) AS count FROM evaluation_attention_loosening_decisions
829
+ WHERE scope_key=? AND lane=? AND source_id=? AND grant_id=? AND decision='adopted'`)
830
+ .get(scopeKey, cohort.lane, cohort.sourceId, grantId).count;
831
+ }
832
+ /** Parameters in force at a notification time, from the append-only adopted/rolled-back events. */
833
+ #eraResolver(scopeKey, cohort) {
834
+ const changes = this.#database.prepare(`SELECT to_version, occurred_at FROM evaluation_attention_tuning_events
835
+ WHERE scope_key=? AND lane=? AND source_id=? AND kind IN ('adopted','rolled-back') ORDER BY sequence`)
836
+ .all(scopeKey, cohort.lane, cohort.sourceId);
837
+ const cache = new Map();
838
+ return notifiedAt => {
839
+ let version = 0;
840
+ for (const change of changes)
841
+ if (change.occurred_at <= notifiedAt)
842
+ version = change.to_version;
843
+ if (version === 0)
844
+ return defaultAttentionTuningParameters;
845
+ if (!cache.has(version))
846
+ cache.set(version, this.#verified(scopeKey, cohort, version)?.parameters);
847
+ return cache.get(version);
848
+ };
849
+ }
850
+ #decision(scopeKey, cohort, proposalId, decision, reason, grantId, version, replay, now) {
851
+ this.#database.prepare(`INSERT INTO evaluation_attention_loosening_decisions
852
+ (proposal_id, scope_key, lane, source_id, decision, reason, grant_id, version, replay_json, decided_at) VALUES (?,?,?,?,?,?,?,?,?,?)`)
853
+ .run(proposalId, scopeKey, cohort.lane, cohort.sourceId, decision, reason, grantId, version, stable(replay), now);
854
+ }
855
+ /**
856
+ * Independent acceptance of one sealed proposal: verify it, require the
857
+ * exact parent still in force, recompute holdout membership against the
858
+ * frozen digest, replay parent vs candidate on holdout outcomes only, then
859
+ * adopt inside the owner grant or record an owner question. Every outcome
860
+ * is one immutable decision row plus a schema-15 tuning event.
861
+ */
862
+ #decideLoosening(scope, scopeKey, cohort, head, row, policy, loosening, observationMs, now) {
863
+ const proposalId = row.proposal_id;
864
+ const current = this.#current(scopeKey, cohort);
865
+ const reject = (reason, replay) => {
866
+ this.#decision(scopeKey, cohort, proposalId, 'rejected', reason, null, null, replay, now);
867
+ this.#event(scopeKey, cohort, 'request-rejected', current.version, current.version, reason, { proposalId, replay }, `loosening:${proposalId}`, now);
868
+ return Object.freeze({ action: 'loosening-rejected', reason, proposalId });
869
+ };
870
+ const proposal = this.#verifiedProposal(row, scopeKey, cohort);
871
+ if (proposal === undefined)
872
+ return reject('proposal-unverifiable', null);
873
+ if (current.version !== proposal.parentVersion || current.digest !== proposal.parentDigest
874
+ || stable(current.parameters) !== stable(proposal.parent))
875
+ return reject('stale-parent', null);
876
+ if (proposal.basis.stats.decided < policy.minSample)
877
+ return reject('insufficient-basis', null);
878
+ const { fromNotifiedAt, toNotifiedAt } = proposal.window;
879
+ const holdout = this.#holdout(scopeKey, cohort, fromNotifiedAt, toNotifiedAt);
880
+ if (holdout.seal.digest !== proposal.holdout.digest)
881
+ return reject('holdout-drifted', null);
882
+ const members = new Set(holdout.ids);
883
+ const era = this.#eraResolver(scopeKey, cohort);
884
+ const records = [];
885
+ for (const record of this.#records(scopeKey, cohort, fromNotifiedAt, toNotifiedAt)) {
886
+ if (!members.has(record.notification_id))
887
+ continue;
888
+ const parameters = era(record.occurred_at);
889
+ if (parameters === undefined)
890
+ return reject('era-unverifiable', null);
891
+ records.push({ era: parameters, outcome: record.outcome });
892
+ }
893
+ const replay = replayAttentionLoosening(records, proposal.parent, proposal.candidate, policy);
894
+ if (replay.verdict === 'fail')
895
+ return reject('holdout-failed', replay);
896
+ const active = this.#activeGrant(scopeKey, cohort);
897
+ const grant = active === 'unverifiable' ? undefined : active?.grant;
898
+ const exceeded = active === 'unverifiable' ? ['grant-unverifiable']
899
+ : attentionGrantExceeded(grant, grant === undefined ? 0 : this.#grantUsed(scopeKey, cohort, grant.grantId), proposal.parent, proposal.candidate, proposal.steps, now);
900
+ if (exceeded.length > 0 || grant === undefined) {
901
+ const replyLoop = loosening.ownerQuestionReplyLoop;
902
+ if (replyLoop !== undefined) {
903
+ const existing = this.#ownerQuestionForProposal(proposalId, scopeKey, cohort);
904
+ if (existing === 'unverifiable')
905
+ return reject('proposal-unverifiable', replay);
906
+ if (existing !== undefined) {
907
+ // Slice 5 deferred state: the proposal stays undecided while the
908
+ // question is open, so the next tick can still adopt it. A terminal
909
+ // decision is written only on decline, unusable authorization or
910
+ // notice TTL expiry, never on first sight. Its event uses the
911
+ // question-scoped key because the question-created event already
912
+ // holds the proposal-scoped key.
913
+ const terminate = (reason) => {
914
+ this.#decision(scopeKey, cohort, proposalId, 'rejected', reason, null, null, replay, now);
915
+ this.#event(scopeKey, cohort, 'request-rejected', current.version, current.version, reason, { proposalId, questionId: existing.questionId, replay }, `owner-question:${existing.questionId}`, now);
916
+ return Object.freeze({ action: 'loosening-rejected', reason, proposalId });
917
+ };
918
+ const reply = this.#questions?.reply(existing.questionId);
919
+ if (reply?.choice === 'decline')
920
+ return terminate('owner-declined');
921
+ if (reply?.choice === 'authorize-once')
922
+ return terminate('owner-authorization-unusable');
923
+ if (now >= existing.question.createdAt + replyLoop.noticeTtlMs)
924
+ return terminate('owner-question-expired');
925
+ return deepFreeze({ action: 'owner-question', questionId: existing.questionId, proposalId, exceeded: [...exceeded] });
926
+ }
927
+ const askedUnder = this.#latestGrantId(scopeKey, cohort);
928
+ const body = {
929
+ protocol: ATTENTION_OWNER_QUESTION_PROTOCOL, kind: 'attention-loosening-authorization', scopeKey,
930
+ cohort: { lane: cohort.lane, sourceId: cohort.sourceId }, proposalId,
931
+ parentVersion: proposal.parentVersion, parentDigest: proposal.parentDigest, parent: proposal.parent, candidate: proposal.candidate,
932
+ change: proposal.change, steps: proposal.steps, replay, exceeded: [...exceeded], grantId: askedUnder,
933
+ choices: ['authorize-once', 'decline'], delivery: 'not-sent', createdAt: now,
934
+ };
935
+ const questionId = sha256(body);
936
+ this.#database.prepare(`INSERT INTO evaluation_attention_owner_questions
937
+ (question_id, proposal_id, scope_key, lane, source_id, question_json, created_at) VALUES (?,?,?,?,?,?,?)`)
938
+ .run(questionId, proposalId, scopeKey, cohort.lane, cohort.sourceId, stable(body), now);
939
+ // No decision row: the deferred proposal remains pending until the
940
+ // reply or TTL closes it, so a one-time grant can still adopt it.
941
+ this.#event(scopeKey, cohort, 'request-rejected', current.version, current.version, 'loosening-requires-owner', { proposalId, questionId, exceeded }, `loosening:${proposalId}`, now);
942
+ return deepFreeze({ action: 'owner-question', questionId, proposalId, exceeded: [...exceeded] });
943
+ }
944
+ const askedUnder = this.#latestGrantId(scopeKey, cohort);
945
+ const body = {
946
+ protocol: ATTENTION_OWNER_QUESTION_PROTOCOL, kind: 'attention-loosening-authorization', scopeKey,
947
+ cohort: { lane: cohort.lane, sourceId: cohort.sourceId }, proposalId,
948
+ parentVersion: proposal.parentVersion, parentDigest: proposal.parentDigest, parent: proposal.parent, candidate: proposal.candidate,
949
+ change: proposal.change, steps: proposal.steps, replay, exceeded: [...exceeded], grantId: askedUnder,
950
+ choices: ['authorize-once', 'decline'], delivery: 'not-sent', createdAt: now,
951
+ };
952
+ const questionId = sha256(body);
953
+ this.#database.prepare(`INSERT INTO evaluation_attention_owner_questions
954
+ (question_id, proposal_id, scope_key, lane, source_id, question_json, created_at) VALUES (?,?,?,?,?,?,?)`)
955
+ .run(questionId, proposalId, scopeKey, cohort.lane, cohort.sourceId, stable(body), now);
956
+ this.#decision(scopeKey, cohort, proposalId, 'owner-question', 'loosening-requires-owner', askedUnder, null, replay, now);
957
+ this.#event(scopeKey, cohort, 'request-rejected', current.version, current.version, 'loosening-requires-owner', { proposalId, questionId, exceeded }, `loosening:${proposalId}`, now);
958
+ return deepFreeze({ action: 'owner-question', questionId, proposalId, exceeded: [...exceeded] });
959
+ }
960
+ const version = this.#adopt(scope, scopeKey, cohort, head, current, { change: proposal.change, parameters: proposal.candidate }, proposal.basis.stats, 'request', observationMs, now, { proposalId, grantId: grant.grantId });
961
+ this.#decision(scopeKey, cohort, proposalId, 'adopted', 'holdout-accepted', grant.grantId, version, replay, now);
962
+ return deepFreeze({ action: 'loosening-adopted', version, change: { ...proposal.change }, proposalId, grantId: grant.grantId });
963
+ }
964
+ /** One immutable question row for a proposal, verified against its content id. */
965
+ #ownerQuestionForProposal(proposalId, scopeKey, cohort) {
966
+ const row = this.#database.prepare(`SELECT question_id, question_json FROM evaluation_attention_owner_questions
967
+ WHERE proposal_id=? AND scope_key=? AND lane=? AND source_id=?`)
968
+ .get(proposalId, scopeKey, cohort.lane, cohort.sourceId);
969
+ if (row === undefined)
970
+ return undefined;
971
+ try {
972
+ const question = JSON.parse(row.question_json);
973
+ if (sha256(question) !== row.question_id || question.protocol !== ATTENTION_OWNER_QUESTION_PROTOCOL
974
+ || question.proposalId !== proposalId || question.scopeKey !== scopeKey
975
+ || question.cohort?.lane !== cohort.lane || question.cohort?.sourceId !== cohort.sourceId)
976
+ return 'unverifiable';
977
+ return deepFreeze({ questionId: row.question_id, question });
978
+ }
979
+ catch {
980
+ return 'unverifiable';
981
+ }
982
+ }
983
+ /**
984
+ * Record an owner one-time pre-authorization. Grants are immutable; the
985
+ * latest one is active and its quota is never refunded. Re-sending the same
986
+ * grant is idempotent; reusing an id with different content is rejected.
987
+ */
988
+ grant(scopeInput, cohortInput, grantInput) {
989
+ const { scopeKey } = canonicalEvaluationScope(scopeInput);
990
+ const cohort = normalizeAttentionCohort(cohortInput);
991
+ const grant = normalizeAttentionLooseningGrant(grantInput);
992
+ return this.#transaction(() => {
993
+ const now = this.#now();
994
+ const digest = this.#grantDigest(scopeKey, cohort, grant);
995
+ const existing = this.#database.prepare(`SELECT digest FROM evaluation_attention_loosening_grants
996
+ WHERE scope_key=? AND lane=? AND source_id=? AND grant_id=?`).get(scopeKey, cohort.lane, cohort.sourceId, grant.grantId);
997
+ if (existing !== undefined) {
998
+ return existing.digest === digest ? Object.freeze({ status: 'granted', grantId: grant.grantId, digest })
999
+ : Object.freeze({ status: 'rejected', reason: 'grant-conflict' });
1000
+ }
1001
+ if (now >= grant.expiresAt)
1002
+ return Object.freeze({ status: 'rejected', reason: 'grant-expired' });
1003
+ this.#database.prepare(`INSERT INTO evaluation_attention_loosening_grants
1004
+ (scope_key, lane, source_id, grant_id, grant_json, digest, created_at) VALUES (?,?,?,?,?,?,?)`)
1005
+ .run(scopeKey, cohort.lane, cohort.sourceId, grant.grantId, stable(grant), digest, now);
1006
+ return Object.freeze({ status: 'granted', grantId: grant.grantId, digest });
1007
+ });
1008
+ }
1009
+ looseningStatus(scopeInput, cohortInput, limit = 50) {
1010
+ const { scopeKey } = canonicalEvaluationScope(scopeInput);
1011
+ const cohort = normalizeAttentionCohort(cohortInput);
1012
+ const active = this.#activeGrant(scopeKey, cohort);
1013
+ const grant = active === undefined || active === 'unverifiable' ? null : (() => {
1014
+ const used = this.#grantUsed(scopeKey, cohort, active.grant.grantId);
1015
+ return { ...active, used, remaining: Math.max(0, active.grant.maxAdoptions - used) };
1016
+ })();
1017
+ const rows = this.#database.prepare(`SELECT * FROM evaluation_attention_loosening_proposals
1018
+ WHERE scope_key=? AND lane=? AND source_id=? ORDER BY created_at DESC, rowid DESC LIMIT ?`)
1019
+ .all(scopeKey, cohort.lane, cohort.sourceId, limit).reverse();
1020
+ const proposals = rows.flatMap(row => {
1021
+ const proposal = this.#verifiedProposal(row, scopeKey, cohort);
1022
+ if (proposal === undefined)
1023
+ return [];
1024
+ const decided = this.#database.prepare(`SELECT decision, reason, grant_id, version, replay_json, decided_at
1025
+ FROM evaluation_attention_loosening_decisions WHERE proposal_id=?`).get(row.proposal_id);
1026
+ const decision = decided === undefined ? null : {
1027
+ decision: decided.decision, reason: decided.reason, grantId: decided.grant_id, version: decided.version,
1028
+ replay: JSON.parse(decided.replay_json), decidedAt: decided.decided_at,
1029
+ };
1030
+ return [{ proposalId: row.proposal_id, proposal, decision }];
1031
+ });
1032
+ const questions = this.#database.prepare(`SELECT question_id, question_json FROM evaluation_attention_owner_questions
1033
+ WHERE scope_key=? AND lane=? AND source_id=? ORDER BY created_at DESC, rowid DESC LIMIT ?`)
1034
+ .all(scopeKey, cohort.lane, cohort.sourceId, limit).reverse()
1035
+ .map(row => ({ questionId: row.question_id, question: JSON.parse(row.question_json) }));
1036
+ return deepFreeze({ grant, proposals, questions });
1037
+ }
1038
+ counts() {
1039
+ const versions = this.#database.prepare('SELECT COUNT(*) AS count FROM evaluation_attention_tuning_versions').get();
1040
+ const events = this.#database.prepare('SELECT COUNT(*) AS count FROM evaluation_attention_tuning_events').get();
1041
+ return { versions: versions.count, events: events.count };
1042
+ }
1043
+ }
1044
+ //# sourceMappingURL=attention-tuning.js.map