@dsh-enhanced/assistant-evaluation 0.1.64 → 0.1.66

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 (80) hide show
  1. package/README.md +54 -1
  2. package/lib/attention-owner-questions.d.ts +85 -0
  3. package/lib/attention-owner-questions.d.ts.map +1 -0
  4. package/lib/attention-owner-questions.js +142 -0
  5. package/lib/attention-owner-questions.js.map +1 -0
  6. package/lib/attention-tuning.d.ts +431 -0
  7. package/lib/attention-tuning.d.ts.map +1 -0
  8. package/lib/attention-tuning.js +892 -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 +11 -6
  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.d.ts +15 -2
  35. package/lib/benchmark/native.d.ts.map +1 -1
  36. package/lib/benchmark/native.js +67 -14
  37. package/lib/benchmark/native.js.map +1 -1
  38. package/lib/benchmark/report.d.ts.map +1 -1
  39. package/lib/benchmark/report.js +98 -8
  40. package/lib/benchmark/report.js.map +1 -1
  41. package/lib/benchmark/runner.d.ts.map +1 -1
  42. package/lib/benchmark/runner.js +5 -0
  43. package/lib/benchmark/runner.js.map +1 -1
  44. package/lib/benchmark/types.d.ts +64 -7
  45. package/lib/benchmark/types.d.ts.map +1 -1
  46. package/lib/index.d.ts +7 -0
  47. package/lib/index.d.ts.map +1 -1
  48. package/lib/index.js +4 -0
  49. package/lib/index.js.map +1 -1
  50. package/lib/intake-attention-agreement.d.ts +63 -0
  51. package/lib/intake-attention-agreement.d.ts.map +1 -0
  52. package/lib/intake-attention-agreement.js +146 -0
  53. package/lib/intake-attention-agreement.js.map +1 -0
  54. package/lib/notification-outcomes.d.ts +56 -0
  55. package/lib/notification-outcomes.d.ts.map +1 -0
  56. package/lib/notification-outcomes.js +348 -0
  57. package/lib/notification-outcomes.js.map +1 -0
  58. package/lib/service.d.ts +244 -2
  59. package/lib/service.d.ts.map +1 -1
  60. package/lib/service.js +830 -5
  61. package/lib/service.js.map +1 -1
  62. package/lib/sqlite.d.ts +8 -2
  63. package/lib/sqlite.d.ts.map +1 -1
  64. package/lib/sqlite.js +406 -17
  65. package/lib/sqlite.js.map +1 -1
  66. package/lib/store.d.ts +13 -2
  67. package/lib/store.d.ts.map +1 -1
  68. package/lib/store.js +120 -24
  69. package/lib/store.js.map +1 -1
  70. package/lib/types.d.ts +247 -5
  71. package/lib/types.d.ts.map +1 -1
  72. package/lib/types.js +11 -0
  73. package/lib/types.js.map +1 -1
  74. package/lib/version.d.ts +1 -1
  75. package/lib/version.js +1 -1
  76. package/lib/work-item-owner-proof.d.ts +4 -0
  77. package/lib/work-item-owner-proof.d.ts.map +1 -0
  78. package/lib/work-item-owner-proof.js +53 -0
  79. package/lib/work-item-owner-proof.js.map +1 -0
  80. package/package.json +30 -30
@@ -0,0 +1,892 @@
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
+ maxStepsPerAdoption: integer('maxStepsPerAdoption', 1, 4),
254
+ maxAdoptions: integer('maxAdoptions', 1, 10),
255
+ expiresAt: integer('expiresAt', 0, Number.MAX_SAFE_INTEGER),
256
+ });
257
+ }
258
+ /** Every way a holdout-accepted loosening falls outside the active grant; empty means adoptable. */
259
+ export function attentionGrantExceeded(grant, used, parent, candidate, steps, now) {
260
+ if (grant === undefined)
261
+ return Object.freeze(['no-grant']);
262
+ const exceeded = [];
263
+ if (now >= grant.expiresAt)
264
+ exceeded.push('grant-expired');
265
+ if (used >= grant.maxAdoptions)
266
+ exceeded.push('grant-exhausted');
267
+ if (!attentionTighterOrEqual(grant.upperBound, parent) || !attentionTighterOrEqual(candidate, grant.lowerBound))
268
+ exceeded.push('outside-bounds');
269
+ if (steps > grant.maxStepsPerAdoption)
270
+ exceeded.push('step-too-large');
271
+ return Object.freeze(exceeded);
272
+ }
273
+ /** Single-step tightening check for an explicit request. */
274
+ function singleStep(current, next) {
275
+ const changed = attentionTuningOrder.filter(key => current[key] !== next[key]);
276
+ if (changed.length !== 1)
277
+ return undefined;
278
+ const parameter = changed[0];
279
+ if (parameter === 'muted')
280
+ return { parameter, from: current.muted, to: next.muted };
281
+ if (next[parameter] - current[parameter] !== attentionTuningBounds[parameter].step)
282
+ return undefined;
283
+ return { parameter, from: current[parameter], to: next[parameter] };
284
+ }
285
+ function versionDigest(input) {
286
+ return sha256({
287
+ protocol: input.protocol, scopeKey: input.scopeKey, cohort: input.cohort, version: input.version,
288
+ parentVersion: input.parentVersion, parentDigest: input.parentDigest, parameters: input.parameters,
289
+ basis: input.basis, change: input.change, origin: input.origin,
290
+ });
291
+ }
292
+ const negativeOutcomes = ['rejected', 'expired-unhandled'];
293
+ /**
294
+ * Cohort filter: a linked-alias fallback report reached the owner on another
295
+ * channel, so its outcome says nothing about the origin cohort's parameters.
296
+ * Stats, holdout seals/replays and cohort discovery all use this filter;
297
+ * route-free (pre-existing) records count as origin.
298
+ */
299
+ const originRoute = `coalesce(json_extract(r.record_json, '$.route.kind'), 'origin')='origin'`;
300
+ /**
301
+ * Durable ledger over the same Evaluation database as the notification canon.
302
+ * All decisions recompute inside BEGIN IMMEDIATE so concurrent ticks or a
303
+ * restart mid-observation converge on one head.
304
+ */
305
+ export class AttentionTuningLedger {
306
+ #database;
307
+ #now;
308
+ constructor(database, now) {
309
+ this.#database = database;
310
+ this.#now = now;
311
+ }
312
+ #transaction(fn) {
313
+ this.#database.exec('BEGIN IMMEDIATE');
314
+ try {
315
+ const value = fn();
316
+ this.#database.exec('COMMIT');
317
+ return value;
318
+ }
319
+ catch (error) {
320
+ this.#database.exec('ROLLBACK');
321
+ throw error;
322
+ }
323
+ }
324
+ /** Current-head (id, outcome, notifiedAt) rows for one cohort, notified in [from, to), in id order. */
325
+ #records(scopeKey, cohort, fromNotifiedAt, toNotifiedAt) {
326
+ return this.#database.prepare(`SELECT h.notification_id AS notification_id, h.outcome AS outcome, h.occurred_at AS occurred_at
327
+ FROM evaluation_notification_outcome_heads h
328
+ JOIN evaluation_notification_outcome_revisions r ON r.notification_id=h.notification_id AND r.version=h.version
329
+ WHERE h.scope_key=? AND h.occurred_at>=? AND h.occurred_at<?
330
+ AND json_extract(r.record_json, '$.lane')=? AND json_extract(r.record_json, '$.sourceId')=?
331
+ AND ${originRoute}
332
+ ORDER BY h.notification_id`).all(scopeKey, fromNotifiedAt, toNotifiedAt, cohort.lane, cohort.sourceId);
333
+ }
334
+ /** Holdout membership for a window: ids only. Outcomes are deliberately not part of the seal. */
335
+ #holdout(scopeKey, cohort, fromNotifiedAt, toNotifiedAt) {
336
+ const ids = this.#records(scopeKey, cohort, fromNotifiedAt, toNotifiedAt).map(row => row.notification_id).filter(attentionHoldoutMember);
337
+ const digest = sha256({ protocol: ATTENTION_HOLDOUT_PROTOCOL, modulus: attentionHoldoutModulus, scopeKey, cohort: { lane: cohort.lane, sourceId: cohort.sourceId },
338
+ fromNotifiedAt, toNotifiedAt, ids });
339
+ return deepFreeze({ ids, seal: { protocol: ATTENTION_HOLDOUT_PROTOCOL, modulus: attentionHoldoutModulus, count: ids.length, digest } });
340
+ }
341
+ /**
342
+ * Current-head outcome counts for one cohort, notified in [from, to).
343
+ * `training` excludes the sealed holdout partition; every proposer uses it
344
+ * once the slice-3 opt-in is on, so no proposal is fitted to holdout data.
345
+ */
346
+ stats(scopeKey, cohort, fromNotifiedAt, toNotifiedAt, partition = 'all') {
347
+ if (partition === 'training') {
348
+ const counts = new Map();
349
+ for (const row of this.#records(scopeKey, cohort, fromNotifiedAt, toNotifiedAt)) {
350
+ if (!attentionHoldoutMember(row.notification_id))
351
+ counts.set(row.outcome, (counts.get(row.outcome) ?? 0) + 1);
352
+ }
353
+ return this.#summarize(fromNotifiedAt, toNotifiedAt, [...counts].map(([outcome, count]) => ({ outcome, count })));
354
+ }
355
+ const rows = this.#database.prepare(`SELECT h.outcome AS outcome, COUNT(*) AS count
356
+ FROM evaluation_notification_outcome_heads h
357
+ JOIN evaluation_notification_outcome_revisions r ON r.notification_id=h.notification_id AND r.version=h.version
358
+ WHERE h.scope_key=? AND h.occurred_at>=? AND h.occurred_at<?
359
+ AND json_extract(r.record_json, '$.lane')=? AND json_extract(r.record_json, '$.sourceId')=?
360
+ AND ${originRoute}
361
+ GROUP BY h.outcome`).all(scopeKey, fromNotifiedAt, toNotifiedAt, cohort.lane, cohort.sourceId);
362
+ return this.#summarize(fromNotifiedAt, toNotifiedAt, rows);
363
+ }
364
+ #summarize(fromNotifiedAt, toNotifiedAt, rows) {
365
+ const count = (outcome) => rows.find(row => row.outcome === outcome)?.count ?? 0;
366
+ const accepted = count('accepted');
367
+ const reacted = count('reacted');
368
+ const rejected = count('rejected');
369
+ const expiredUnhandled = count('expired-unhandled');
370
+ return Object.freeze({
371
+ fromNotifiedAt, toNotifiedAt,
372
+ total: rows.reduce((sum, row) => sum + row.count, 0),
373
+ accepted, reacted, rejected, expiredUnhandled,
374
+ decided: accepted + reacted + rejected + expiredUnhandled,
375
+ negative: negativeOutcomes.reduce((sum, outcome) => sum + count(outcome), 0),
376
+ });
377
+ }
378
+ /** Scopes that have at least one notification in the cohort, plus scopes with a tuning head. */
379
+ scopes(cohort) {
380
+ const rows = this.#database.prepare(`SELECT DISTINCT json_extract(r.record_json, '$.scope.workspace') AS workspace,
381
+ json_extract(r.record_json, '$.scope.preset') AS preset
382
+ FROM evaluation_notification_outcome_heads h
383
+ JOIN evaluation_notification_outcome_revisions r ON r.notification_id=h.notification_id AND r.version=h.version
384
+ WHERE json_extract(r.record_json, '$.lane')=? AND json_extract(r.record_json, '$.sourceId')=? AND ${originRoute}
385
+ UNION SELECT workspace, preset FROM evaluation_attention_tuning_heads WHERE lane=? AND source_id=?
386
+ ORDER BY workspace, preset`).all(cohort.lane, cohort.sourceId, cohort.lane, cohort.sourceId);
387
+ return Object.freeze(rows.map(row => Object.freeze({ workspace: row.workspace, preset: row.preset })));
388
+ }
389
+ #scopeWatermark(scopeKey) {
390
+ return this.#database.prepare('SELECT watermark FROM evaluation_notification_watermarks WHERE scope_key=?')
391
+ .get(scopeKey)?.watermark ?? 0;
392
+ }
393
+ #head(scopeKey, cohort) {
394
+ return this.#database.prepare('SELECT * FROM evaluation_attention_tuning_heads WHERE scope_key=? AND lane=? AND source_id=?')
395
+ .get(scopeKey, cohort.lane, cohort.sourceId);
396
+ }
397
+ #row(scopeKey, cohort, version) {
398
+ return this.#database.prepare('SELECT * FROM evaluation_attention_tuning_versions WHERE scope_key=? AND lane=? AND source_id=? AND version=?')
399
+ .get(scopeKey, cohort.lane, cohort.sourceId, version);
400
+ }
401
+ /** Parse and digest-verify one stored version; undefined when unreadable or tampered. */
402
+ #verified(scopeKey, cohort, version) {
403
+ const row = this.#row(scopeKey, cohort, version);
404
+ if (row === undefined)
405
+ return undefined;
406
+ try {
407
+ const parentDigest = row.parent_version === 0 ? null : this.#row(scopeKey, cohort, row.parent_version)?.digest ?? null;
408
+ if (row.parent_version !== 0 && parentDigest === null)
409
+ return undefined;
410
+ const parameters = normalizeAttentionTuningParameters(JSON.parse(row.parameters_json));
411
+ if (!withinAttentionTuningBounds(parameters))
412
+ return undefined;
413
+ const body = {
414
+ protocol: ATTENTION_TUNING_PROTOCOL, scopeKey, cohort: { lane: cohort.lane, sourceId: cohort.sourceId }, version: row.version,
415
+ parentVersion: row.parent_version, parentDigest, parameters,
416
+ basis: JSON.parse(row.basis_json),
417
+ change: JSON.parse(row.change_json), origin: row.origin,
418
+ };
419
+ if (versionDigest(body) !== row.digest)
420
+ return undefined;
421
+ return deepFreeze({ ...body, digest: row.digest, createdAt: row.created_at });
422
+ }
423
+ catch {
424
+ return undefined;
425
+ }
426
+ }
427
+ #versionNumbers(scopeKey, cohort) {
428
+ return this.#database.prepare('SELECT version FROM evaluation_attention_tuning_versions WHERE scope_key=? AND lane=? AND source_id=? ORDER BY version')
429
+ .all(scopeKey, cohort.lane, cohort.sourceId).map(row => row.version);
430
+ }
431
+ /**
432
+ * Active parameters for a consumer. A version that cannot be read or fails
433
+ * digest verification falls back along its recorded parent chain, then to
434
+ * the built-in default; a consumer never sees an unverified overlay.
435
+ */
436
+ current(scopeInput, cohortInput) {
437
+ const { scopeKey } = canonicalEvaluationScope(scopeInput);
438
+ const cohort = normalizeAttentionCohort(cohortInput);
439
+ return this.#current(scopeKey, cohort);
440
+ }
441
+ #current(scopeKey, cohort) {
442
+ const active = this.#head(scopeKey, cohort)?.active_version ?? 0;
443
+ let version = active;
444
+ let fallback = false;
445
+ const seen = new Set();
446
+ while (version > 0 && !seen.has(version)) {
447
+ seen.add(version);
448
+ const verified = this.#verified(scopeKey, cohort, version);
449
+ if (verified !== undefined) {
450
+ return deepFreeze({ scopeKey, cohort: { ...cohort }, version, digest: verified.digest, parameters: { ...verified.parameters }, source: fallback ? 'parent-fallback' : 'active' });
451
+ }
452
+ fallback = true;
453
+ const parent = this.#database.prepare('SELECT parent_version FROM evaluation_attention_tuning_versions WHERE scope_key=? AND lane=? AND source_id=? AND version=?')
454
+ .get(scopeKey, cohort.lane, cohort.sourceId, version)?.parent_version;
455
+ version = Number.isSafeInteger(parent) && parent >= 0 && parent < version ? parent : 0;
456
+ }
457
+ return deepFreeze({ scopeKey, cohort: { ...cohort }, version: 0, digest: null, parameters: { ...defaultAttentionTuningParameters }, source: active === 0 ? 'active' : 'default' });
458
+ }
459
+ status(scopeInput, cohortInput, eventLimit = 50) {
460
+ const { scopeKey } = canonicalEvaluationScope(scopeInput);
461
+ const cohort = normalizeAttentionCohort(cohortInput);
462
+ const head = this.#head(scopeKey, cohort);
463
+ const versions = this.#versionNumbers(scopeKey, cohort)
464
+ .map(version => this.#verified(scopeKey, cohort, version)).filter(value => value !== undefined);
465
+ const events = this.#database.prepare(`SELECT sequence, kind, from_version, to_version, reason, detail_json, occurred_at
466
+ FROM evaluation_attention_tuning_events WHERE scope_key=? AND lane=? AND source_id=? ORDER BY sequence DESC LIMIT ?`)
467
+ .all(scopeKey, cohort.lane, cohort.sourceId, eventLimit).reverse().map(row => ({
468
+ sequence: row.sequence, kind: row.kind, fromVersion: row.from_version, toVersion: row.to_version,
469
+ reason: row.reason, detail: JSON.parse(row.detail_json), occurredAt: row.occurred_at,
470
+ }));
471
+ return deepFreeze({
472
+ current: this.#current(scopeKey, cohort),
473
+ state: head?.state ?? 'idle',
474
+ ...(head?.observe_from == null ? {} : { observeFrom: head.observe_from }),
475
+ ...(head?.observe_until == null ? {} : { observeUntil: head.observe_until }),
476
+ versions, events,
477
+ });
478
+ }
479
+ #event(scopeKey, cohort, kind, fromVersion, toVersion, reason, detail, idempotencyKey, now) {
480
+ this.#database.prepare(`INSERT INTO evaluation_attention_tuning_events
481
+ (scope_key, lane, source_id, kind, from_version, to_version, reason, detail_json, idempotency_key, occurred_at) VALUES (?,?,?,?,?,?,?,?,?,?)
482
+ ON CONFLICT(idempotency_key) DO NOTHING`)
483
+ .run(scopeKey, cohort.lane, cohort.sourceId, kind, fromVersion, toVersion, reason, stable(detail), idempotencyKey, now);
484
+ }
485
+ #adopt(scope, scopeKey, cohort, head, current, proposal, stats, origin, observationMs, now, loosening) {
486
+ // Parent is the verified version the consumer is actually running.
487
+ const parentVersion = current.version;
488
+ const scopeWatermark = this.#scopeWatermark(scopeKey);
489
+ const idempotencyKey = `adopt:${sha256([scopeKey, cohort.lane, cohort.sourceId, parentVersion, current.digest, scopeWatermark, proposal.change, origin,
490
+ ...(loosening === undefined ? [] : [loosening.proposalId])])}`;
491
+ const existing = this.#database.prepare('SELECT version FROM evaluation_attention_tuning_versions WHERE idempotency_key=?')
492
+ .get(idempotencyKey);
493
+ if (existing !== undefined)
494
+ return existing.version;
495
+ const version = (head?.latest_version ?? 0) + 1;
496
+ const body = {
497
+ protocol: ATTENTION_TUNING_PROTOCOL, scopeKey, cohort: { lane: cohort.lane, sourceId: cohort.sourceId }, version,
498
+ parentVersion, parentDigest: current.digest, parameters: proposal.parameters,
499
+ basis: { stats, scopeWatermark }, change: proposal.change, origin,
500
+ };
501
+ const digest = versionDigest(body);
502
+ this.#database.prepare(`INSERT INTO evaluation_attention_tuning_versions
503
+ (scope_key, lane, source_id, version, parent_version, parameters_json, basis_json, change_json, origin, digest, idempotency_key, created_at)
504
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`).run(scopeKey, cohort.lane, cohort.sourceId, version, parentVersion, stable(body.parameters), stable(body.basis), stable(body.change), origin, digest, idempotencyKey, now);
505
+ const observeUntil = now + observationMs;
506
+ if (head === undefined) {
507
+ this.#database.prepare(`INSERT INTO evaluation_attention_tuning_heads
508
+ (scope_key, lane, source_id, workspace, preset, active_version, latest_version, state, observing_version, observe_from, observe_until, changed_at)
509
+ VALUES (?,?,?,?,?,?,?, 'observing', ?,?,?,?)`).run(scopeKey, cohort.lane, cohort.sourceId, scope.workspace, scope.preset, version, version, version, now, observeUntil, now);
510
+ }
511
+ else {
512
+ this.#database.prepare(`UPDATE evaluation_attention_tuning_heads SET active_version=?, latest_version=?, state='observing',
513
+ observing_version=?, observe_from=?, observe_until=?, changed_at=? WHERE scope_key=? AND lane=? AND source_id=?`)
514
+ .run(version, version, version, now, observeUntil, now, scopeKey, cohort.lane, cohort.sourceId);
515
+ }
516
+ 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);
517
+ return version;
518
+ }
519
+ /**
520
+ * One deterministic step for one cohort: monitor an observing version
521
+ * (rollback to its exact parent on regression, settle at window end), or
522
+ * when idle propose and adopt a single bounded tightening step.
523
+ */
524
+ tick(scopeInput, cohortInput, policy, options) {
525
+ const { scope, scopeKey } = canonicalEvaluationScope(scopeInput);
526
+ const cohort = normalizeAttentionCohort(cohortInput);
527
+ return this.#transaction(() => {
528
+ const now = this.#now();
529
+ const head = this.#head(scopeKey, cohort);
530
+ if (head?.state === 'observing') {
531
+ const observed = head.observing_version;
532
+ const version = this.#verified(scopeKey, cohort, observed);
533
+ const post = this.stats(scopeKey, cohort, head.observe_from, now + 1);
534
+ // An unverifiable observing version is reverted exactly like a regression.
535
+ if (version === undefined || attentionRegressed(version.basis.stats, post, policy)) {
536
+ const parent = this.#database.prepare('SELECT parent_version FROM evaluation_attention_tuning_versions WHERE scope_key=? AND lane=? AND source_id=? AND version=?')
537
+ .get(scopeKey, cohort.lane, cohort.sourceId, observed)?.parent_version ?? 0;
538
+ this.#database.prepare(`UPDATE evaluation_attention_tuning_heads SET active_version=?, state='idle', observing_version=NULL,
539
+ observe_from=NULL, observe_until=NULL, changed_at=? WHERE scope_key=? AND lane=? AND source_id=?`)
540
+ .run(parent, now, scopeKey, cohort.lane, cohort.sourceId);
541
+ 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);
542
+ return Object.freeze({ action: 'rolled-back', fromVersion: observed, toVersion: parent });
543
+ }
544
+ if (now >= head.observe_until) {
545
+ this.#database.prepare(`UPDATE evaluation_attention_tuning_heads SET state='idle', observing_version=NULL,
546
+ observe_from=NULL, observe_until=NULL, changed_at=? WHERE scope_key=? AND lane=? AND source_id=?`)
547
+ .run(now, scopeKey, cohort.lane, cohort.sourceId);
548
+ this.#event(scopeKey, cohort, 'settled', observed, observed, 'observation-complete', { post }, `settle:${sha256([scopeKey, cohort.lane, cohort.sourceId, observed, head.observe_from])}`, now);
549
+ return Object.freeze({ action: 'settled', version: observed });
550
+ }
551
+ return Object.freeze({ action: 'observing', version: observed });
552
+ }
553
+ if (options.loosening !== undefined) {
554
+ // A sealed loosening proposal is judged before any new tightening so
555
+ // its parent cannot move underneath it.
556
+ const pending = this.#pendingProposal(scopeKey, cohort);
557
+ if (pending !== undefined)
558
+ return this.#decideLoosening(scope, scopeKey, cohort, head, pending, policy, options.observationMs, now);
559
+ }
560
+ const current = this.#current(scopeKey, cohort);
561
+ // Evidence is only counted since the last change, so an adopted or
562
+ // rolled-back version is never re-judged on its predecessor's sample.
563
+ const from = Math.max(0, head?.changed_at ?? 0, now - options.evaluationWindowMs);
564
+ const stats = this.stats(scopeKey, cohort, from, now + 1, options.loosening === undefined ? 'all' : 'training');
565
+ const proposal = proposeAttentionTuning(current.parameters, stats, policy);
566
+ if (proposal.kind === 'no-op')
567
+ return Object.freeze({ action: 'no-op', reason: proposal.reason });
568
+ const version = this.#adopt(scope, scopeKey, cohort, head, current, proposal, stats, 'auto', options.observationMs, now);
569
+ return Object.freeze({ action: 'adopted', version, change: proposal.change });
570
+ });
571
+ }
572
+ /**
573
+ * Explicit parameter request. Any loosening field is rejected with
574
+ * `loosening-requires-holdout` and recorded; so is every other rejection.
575
+ * A single in-bounds tightening step is adopted with an observation window.
576
+ */
577
+ request(scopeInput, cohortInput, parametersInput, options) {
578
+ const { scope, scopeKey } = canonicalEvaluationScope(scopeInput);
579
+ const cohort = normalizeAttentionCohort(cohortInput);
580
+ const requested = normalizeAttentionTuningParameters(parametersInput);
581
+ return this.#transaction(() => {
582
+ const now = this.#now();
583
+ const head = this.#head(scopeKey, cohort);
584
+ const current = this.#current(scopeKey, cohort);
585
+ if (options.loosening !== undefined && isAttentionLoosening(current.parameters, requested)) {
586
+ return this.#proposeLoosening(scopeKey, cohort, head, current, requested, options.enabled, options.loosening, now);
587
+ }
588
+ const change = singleStep(current.parameters, requested);
589
+ const reason = isAttentionLoosening(current.parameters, requested) ? 'loosening-requires-holdout'
590
+ : !withinAttentionTuningBounds(requested) ? 'out-of-bounds'
591
+ : stable(requested) === stable(current.parameters) ? 'no-change'
592
+ : change === undefined ? 'not-single-step'
593
+ : !options.enabled ? 'auto-adoption-disabled'
594
+ : head?.state === 'observing' ? 'observation-in-progress'
595
+ : undefined;
596
+ if (reason !== undefined || change === undefined) {
597
+ const code = reason ?? 'not-single-step';
598
+ this.#event(scopeKey, cohort, 'request-rejected', current.version, current.version, code, { current: current.parameters, requested }, null, now);
599
+ return Object.freeze({ status: 'rejected', reason: code });
600
+ }
601
+ const stats = this.stats(scopeKey, cohort, Math.max(0, head?.changed_at ?? 0, now - options.evaluationWindowMs), now + 1, options.loosening === undefined ? 'all' : 'training');
602
+ const version = this.#adopt(scope, scopeKey, cohort, head, current, { change, parameters: requested }, stats, 'request', options.observationMs, now);
603
+ return deepFreeze({ status: 'adopted', version, change });
604
+ });
605
+ }
606
+ /**
607
+ * Slice 3 loosening request: validate, then seal a proposal that names the
608
+ * holdout membership by digest only. Nothing is adopted here; a later tick
609
+ * replays the holdout independently and checks the owner grant.
610
+ */
611
+ #proposeLoosening(scopeKey, cohort, head, current, requested, enabled, loosening, now) {
612
+ const change = attentionLooseningChange(current.parameters, requested);
613
+ const reason = !withinAttentionTuningBounds(requested) ? 'out-of-bounds'
614
+ : change === undefined ? 'not-single-parameter'
615
+ : !enabled ? 'auto-adoption-disabled'
616
+ : head?.state === 'observing' ? 'observation-in-progress'
617
+ : this.#pendingProposal(scopeKey, cohort) !== undefined ? 'proposal-pending'
618
+ : this.#openQuestion(scopeKey, cohort) ? 'owner-question-pending'
619
+ : undefined;
620
+ if (reason !== undefined || change === undefined) {
621
+ const code = reason ?? 'not-single-parameter';
622
+ this.#event(scopeKey, cohort, 'request-rejected', current.version, current.version, code, { current: current.parameters, requested }, null, now);
623
+ return Object.freeze({ status: 'rejected', reason: code });
624
+ }
625
+ const window = { fromNotifiedAt: Math.max(0, now - loosening.replayWindowMs), toNotifiedAt: now + 1 };
626
+ const holdout = this.#holdout(scopeKey, cohort, window.fromNotifiedAt, window.toNotifiedAt);
627
+ const body = {
628
+ protocol: ATTENTION_LOOSENING_PROTOCOL, scopeKey, cohort: { lane: cohort.lane, sourceId: cohort.sourceId },
629
+ ordinal: this.#database.prepare(`SELECT COUNT(*) AS count FROM evaluation_attention_loosening_proposals
630
+ WHERE scope_key=? AND lane=? AND source_id=?`).get(scopeKey, cohort.lane, cohort.sourceId).count + 1,
631
+ parentVersion: current.version, parentDigest: current.digest, parent: { ...current.parameters }, candidate: { ...requested },
632
+ change: { parameter: change.parameter, from: change.from, to: change.to }, steps: change.steps, window,
633
+ holdout: holdout.seal,
634
+ basis: { stats: this.stats(scopeKey, cohort, window.fromNotifiedAt, window.toNotifiedAt, 'training'), scopeWatermark: this.#scopeWatermark(scopeKey) },
635
+ createdAt: now,
636
+ };
637
+ const proposalId = sha256(body);
638
+ this.#database.prepare(`INSERT INTO evaluation_attention_loosening_proposals
639
+ (proposal_id, scope_key, lane, source_id, parent_version, proposal_json, holdout_digest, created_at) VALUES (?,?,?,?,?,?,?,?)`)
640
+ .run(proposalId, scopeKey, cohort.lane, cohort.sourceId, current.version, stable(body), holdout.seal.digest, now);
641
+ return deepFreeze({ status: 'proposed', proposalId, holdout: { ...holdout.seal } });
642
+ }
643
+ #pendingProposal(scopeKey, cohort) {
644
+ return this.#database.prepare(`SELECT p.* FROM evaluation_attention_loosening_proposals p
645
+ LEFT JOIN evaluation_attention_loosening_decisions d ON d.proposal_id=p.proposal_id
646
+ WHERE p.scope_key=? AND p.lane=? AND p.source_id=? AND d.proposal_id IS NULL
647
+ ORDER BY p.created_at, p.rowid LIMIT 1`).get(scopeKey, cohort.lane, cohort.sourceId);
648
+ }
649
+ /**
650
+ * The latest owner question stays open until a different grant is recorded:
651
+ * it names the latest grant row it was asked under (grant ids are unique
652
+ * per cohort), so only a new owner authorization reopens the path.
653
+ */
654
+ #openQuestion(scopeKey, cohort) {
655
+ const question = this.#database.prepare(`SELECT question_json FROM evaluation_attention_owner_questions
656
+ WHERE scope_key=? AND lane=? AND source_id=? ORDER BY created_at DESC, rowid DESC LIMIT 1`)
657
+ .get(scopeKey, cohort.lane, cohort.sourceId);
658
+ if (question === undefined)
659
+ return false;
660
+ try {
661
+ return JSON.parse(question.question_json).grantId === this.#latestGrantId(scopeKey, cohort);
662
+ }
663
+ catch {
664
+ return true;
665
+ }
666
+ }
667
+ /**
668
+ * WP22 slice 4: the latest owner question of a cohort, verified against its
669
+ * content id, and whether it is still open (same rule as `#openQuestion`).
670
+ * Older questions are always closed. A row whose body no longer hashes to
671
+ * its id is reported as `unverifiable` and must never be delivered.
672
+ */
673
+ latestOwnerQuestion(scopeInput, cohortInput) {
674
+ const { scopeKey } = canonicalEvaluationScope(scopeInput);
675
+ const cohort = normalizeAttentionCohort(cohortInput);
676
+ const row = this.#database.prepare(`SELECT question_id, question_json FROM evaluation_attention_owner_questions
677
+ WHERE scope_key=? AND lane=? AND source_id=? ORDER BY created_at DESC, rowid DESC LIMIT 1`)
678
+ .get(scopeKey, cohort.lane, cohort.sourceId);
679
+ if (row === undefined)
680
+ return undefined;
681
+ let question;
682
+ try {
683
+ question = JSON.parse(row.question_json);
684
+ }
685
+ catch {
686
+ return 'unverifiable';
687
+ }
688
+ if (sha256(question) !== row.question_id || question.protocol !== ATTENTION_OWNER_QUESTION_PROTOCOL
689
+ || question.scopeKey !== scopeKey || question.cohort?.lane !== cohort.lane || question.cohort?.sourceId !== cohort.sourceId)
690
+ return 'unverifiable';
691
+ return deepFreeze({ questionId: row.question_id, question, open: question.grantId === this.#latestGrantId(scopeKey, cohort) });
692
+ }
693
+ #latestGrantId(scopeKey, cohort) {
694
+ return this.#database.prepare(`SELECT grant_id FROM evaluation_attention_loosening_grants
695
+ WHERE scope_key=? AND lane=? AND source_id=? ORDER BY created_at DESC, rowid DESC LIMIT 1`)
696
+ .get(scopeKey, cohort.lane, cohort.sourceId)?.grant_id ?? null;
697
+ }
698
+ #verifiedProposal(row, scopeKey, cohort) {
699
+ try {
700
+ const body = JSON.parse(row.proposal_json);
701
+ if (sha256(body) !== row.proposal_id || body.protocol !== ATTENTION_LOOSENING_PROTOCOL || body.scopeKey !== scopeKey
702
+ || body.cohort.lane !== cohort.lane || body.cohort.sourceId !== cohort.sourceId || body.parentVersion !== row.parent_version
703
+ || body.holdout.digest !== row.holdout_digest || body.holdout.protocol !== ATTENTION_HOLDOUT_PROTOCOL || body.holdout.modulus !== attentionHoldoutModulus)
704
+ return undefined;
705
+ const parent = normalizeAttentionTuningParameters(body.parent);
706
+ const candidate = normalizeAttentionTuningParameters(body.candidate);
707
+ const change = attentionLooseningChange(parent, candidate);
708
+ if (!withinAttentionTuningBounds(parent) || !withinAttentionTuningBounds(candidate) || change === undefined || change.steps !== body.steps)
709
+ return undefined;
710
+ return deepFreeze(body);
711
+ }
712
+ catch {
713
+ return undefined;
714
+ }
715
+ }
716
+ #grantDigest(scopeKey, cohort, grant) {
717
+ return sha256({ protocol: ATTENTION_LOOSENING_GRANT_PROTOCOL, scopeKey, cohort: { lane: cohort.lane, sourceId: cohort.sourceId }, grant });
718
+ }
719
+ /** Latest grant for the cohort; `unverifiable` when its stored body fails the digest. */
720
+ #activeGrant(scopeKey, cohort) {
721
+ const row = this.#database.prepare(`SELECT grant_id, grant_json, digest, created_at FROM evaluation_attention_loosening_grants
722
+ WHERE scope_key=? AND lane=? AND source_id=? ORDER BY created_at DESC, rowid DESC LIMIT 1`)
723
+ .get(scopeKey, cohort.lane, cohort.sourceId);
724
+ if (row === undefined)
725
+ return undefined;
726
+ try {
727
+ const grant = normalizeAttentionLooseningGrant(JSON.parse(row.grant_json));
728
+ if (grant.grantId !== row.grant_id || this.#grantDigest(scopeKey, cohort, grant) !== row.digest)
729
+ return 'unverifiable';
730
+ return Object.freeze({ grant, digest: row.digest, createdAt: row.created_at });
731
+ }
732
+ catch {
733
+ return 'unverifiable';
734
+ }
735
+ }
736
+ /** Adoptions charged to a grant. Decisions are immutable, so a rollback never refunds quota. */
737
+ #grantUsed(scopeKey, cohort, grantId) {
738
+ return this.#database.prepare(`SELECT COUNT(*) AS count FROM evaluation_attention_loosening_decisions
739
+ WHERE scope_key=? AND lane=? AND source_id=? AND grant_id=? AND decision='adopted'`)
740
+ .get(scopeKey, cohort.lane, cohort.sourceId, grantId).count;
741
+ }
742
+ /** Parameters in force at a notification time, from the append-only adopted/rolled-back events. */
743
+ #eraResolver(scopeKey, cohort) {
744
+ const changes = this.#database.prepare(`SELECT to_version, occurred_at FROM evaluation_attention_tuning_events
745
+ WHERE scope_key=? AND lane=? AND source_id=? AND kind IN ('adopted','rolled-back') ORDER BY sequence`)
746
+ .all(scopeKey, cohort.lane, cohort.sourceId);
747
+ const cache = new Map();
748
+ return notifiedAt => {
749
+ let version = 0;
750
+ for (const change of changes)
751
+ if (change.occurred_at <= notifiedAt)
752
+ version = change.to_version;
753
+ if (version === 0)
754
+ return defaultAttentionTuningParameters;
755
+ if (!cache.has(version))
756
+ cache.set(version, this.#verified(scopeKey, cohort, version)?.parameters);
757
+ return cache.get(version);
758
+ };
759
+ }
760
+ #decision(scopeKey, cohort, proposalId, decision, reason, grantId, version, replay, now) {
761
+ this.#database.prepare(`INSERT INTO evaluation_attention_loosening_decisions
762
+ (proposal_id, scope_key, lane, source_id, decision, reason, grant_id, version, replay_json, decided_at) VALUES (?,?,?,?,?,?,?,?,?,?)`)
763
+ .run(proposalId, scopeKey, cohort.lane, cohort.sourceId, decision, reason, grantId, version, stable(replay), now);
764
+ }
765
+ /**
766
+ * Independent acceptance of one sealed proposal: verify it, require the
767
+ * exact parent still in force, recompute holdout membership against the
768
+ * frozen digest, replay parent vs candidate on holdout outcomes only, then
769
+ * adopt inside the owner grant or record an owner question. Every outcome
770
+ * is one immutable decision row plus a schema-15 tuning event.
771
+ */
772
+ #decideLoosening(scope, scopeKey, cohort, head, row, policy, observationMs, now) {
773
+ const proposalId = row.proposal_id;
774
+ const current = this.#current(scopeKey, cohort);
775
+ const reject = (reason, replay) => {
776
+ this.#decision(scopeKey, cohort, proposalId, 'rejected', reason, null, null, replay, now);
777
+ this.#event(scopeKey, cohort, 'request-rejected', current.version, current.version, reason, { proposalId, replay }, `loosening:${proposalId}`, now);
778
+ return Object.freeze({ action: 'loosening-rejected', reason, proposalId });
779
+ };
780
+ const proposal = this.#verifiedProposal(row, scopeKey, cohort);
781
+ if (proposal === undefined)
782
+ return reject('proposal-unverifiable', null);
783
+ if (current.version !== proposal.parentVersion || current.digest !== proposal.parentDigest
784
+ || stable(current.parameters) !== stable(proposal.parent))
785
+ return reject('stale-parent', null);
786
+ if (proposal.basis.stats.decided < policy.minSample)
787
+ return reject('insufficient-basis', null);
788
+ const { fromNotifiedAt, toNotifiedAt } = proposal.window;
789
+ const holdout = this.#holdout(scopeKey, cohort, fromNotifiedAt, toNotifiedAt);
790
+ if (holdout.seal.digest !== proposal.holdout.digest)
791
+ return reject('holdout-drifted', null);
792
+ const members = new Set(holdout.ids);
793
+ const era = this.#eraResolver(scopeKey, cohort);
794
+ const records = [];
795
+ for (const record of this.#records(scopeKey, cohort, fromNotifiedAt, toNotifiedAt)) {
796
+ if (!members.has(record.notification_id))
797
+ continue;
798
+ const parameters = era(record.occurred_at);
799
+ if (parameters === undefined)
800
+ return reject('era-unverifiable', null);
801
+ records.push({ era: parameters, outcome: record.outcome });
802
+ }
803
+ const replay = replayAttentionLoosening(records, proposal.parent, proposal.candidate, policy);
804
+ if (replay.verdict === 'fail')
805
+ return reject('holdout-failed', replay);
806
+ const active = this.#activeGrant(scopeKey, cohort);
807
+ const grant = active === 'unverifiable' ? undefined : active?.grant;
808
+ const exceeded = active === 'unverifiable' ? ['grant-unverifiable']
809
+ : attentionGrantExceeded(grant, grant === undefined ? 0 : this.#grantUsed(scopeKey, cohort, grant.grantId), proposal.parent, proposal.candidate, proposal.steps, now);
810
+ if (exceeded.length > 0 || grant === undefined) {
811
+ const askedUnder = this.#latestGrantId(scopeKey, cohort);
812
+ const body = {
813
+ protocol: ATTENTION_OWNER_QUESTION_PROTOCOL, kind: 'attention-loosening-authorization', scopeKey,
814
+ cohort: { lane: cohort.lane, sourceId: cohort.sourceId }, proposalId,
815
+ parentVersion: proposal.parentVersion, parentDigest: proposal.parentDigest, parent: proposal.parent, candidate: proposal.candidate,
816
+ change: proposal.change, steps: proposal.steps, replay, exceeded: [...exceeded], grantId: askedUnder,
817
+ choices: ['authorize-once', 'decline'], delivery: 'not-sent', createdAt: now,
818
+ };
819
+ const questionId = sha256(body);
820
+ this.#database.prepare(`INSERT INTO evaluation_attention_owner_questions
821
+ (question_id, proposal_id, scope_key, lane, source_id, question_json, created_at) VALUES (?,?,?,?,?,?,?)`)
822
+ .run(questionId, proposalId, scopeKey, cohort.lane, cohort.sourceId, stable(body), now);
823
+ this.#decision(scopeKey, cohort, proposalId, 'owner-question', 'loosening-requires-owner', askedUnder, null, replay, now);
824
+ this.#event(scopeKey, cohort, 'request-rejected', current.version, current.version, 'loosening-requires-owner', { proposalId, questionId, exceeded }, `loosening:${proposalId}`, now);
825
+ return deepFreeze({ action: 'owner-question', questionId, proposalId, exceeded: [...exceeded] });
826
+ }
827
+ 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 });
828
+ this.#decision(scopeKey, cohort, proposalId, 'adopted', 'holdout-accepted', grant.grantId, version, replay, now);
829
+ return deepFreeze({ action: 'loosening-adopted', version, change: { ...proposal.change }, proposalId, grantId: grant.grantId });
830
+ }
831
+ /**
832
+ * Record an owner one-time pre-authorization. Grants are immutable; the
833
+ * latest one is active and its quota is never refunded. Re-sending the same
834
+ * grant is idempotent; reusing an id with different content is rejected.
835
+ */
836
+ grant(scopeInput, cohortInput, grantInput) {
837
+ const { scopeKey } = canonicalEvaluationScope(scopeInput);
838
+ const cohort = normalizeAttentionCohort(cohortInput);
839
+ const grant = normalizeAttentionLooseningGrant(grantInput);
840
+ return this.#transaction(() => {
841
+ const now = this.#now();
842
+ const digest = this.#grantDigest(scopeKey, cohort, grant);
843
+ const existing = this.#database.prepare(`SELECT digest FROM evaluation_attention_loosening_grants
844
+ WHERE scope_key=? AND lane=? AND source_id=? AND grant_id=?`).get(scopeKey, cohort.lane, cohort.sourceId, grant.grantId);
845
+ if (existing !== undefined) {
846
+ return existing.digest === digest ? Object.freeze({ status: 'granted', grantId: grant.grantId, digest })
847
+ : Object.freeze({ status: 'rejected', reason: 'grant-conflict' });
848
+ }
849
+ if (now >= grant.expiresAt)
850
+ return Object.freeze({ status: 'rejected', reason: 'grant-expired' });
851
+ this.#database.prepare(`INSERT INTO evaluation_attention_loosening_grants
852
+ (scope_key, lane, source_id, grant_id, grant_json, digest, created_at) VALUES (?,?,?,?,?,?,?)`)
853
+ .run(scopeKey, cohort.lane, cohort.sourceId, grant.grantId, stable(grant), digest, now);
854
+ return Object.freeze({ status: 'granted', grantId: grant.grantId, digest });
855
+ });
856
+ }
857
+ looseningStatus(scopeInput, cohortInput, limit = 50) {
858
+ const { scopeKey } = canonicalEvaluationScope(scopeInput);
859
+ const cohort = normalizeAttentionCohort(cohortInput);
860
+ const active = this.#activeGrant(scopeKey, cohort);
861
+ const grant = active === undefined || active === 'unverifiable' ? null : (() => {
862
+ const used = this.#grantUsed(scopeKey, cohort, active.grant.grantId);
863
+ return { ...active, used, remaining: Math.max(0, active.grant.maxAdoptions - used) };
864
+ })();
865
+ const rows = this.#database.prepare(`SELECT * FROM evaluation_attention_loosening_proposals
866
+ WHERE scope_key=? AND lane=? AND source_id=? ORDER BY created_at DESC, rowid DESC LIMIT ?`)
867
+ .all(scopeKey, cohort.lane, cohort.sourceId, limit).reverse();
868
+ const proposals = rows.flatMap(row => {
869
+ const proposal = this.#verifiedProposal(row, scopeKey, cohort);
870
+ if (proposal === undefined)
871
+ return [];
872
+ const decided = this.#database.prepare(`SELECT decision, reason, grant_id, version, replay_json, decided_at
873
+ FROM evaluation_attention_loosening_decisions WHERE proposal_id=?`).get(row.proposal_id);
874
+ const decision = decided === undefined ? null : {
875
+ decision: decided.decision, reason: decided.reason, grantId: decided.grant_id, version: decided.version,
876
+ replay: JSON.parse(decided.replay_json), decidedAt: decided.decided_at,
877
+ };
878
+ return [{ proposalId: row.proposal_id, proposal, decision }];
879
+ });
880
+ const questions = this.#database.prepare(`SELECT question_id, question_json FROM evaluation_attention_owner_questions
881
+ WHERE scope_key=? AND lane=? AND source_id=? ORDER BY created_at DESC, rowid DESC LIMIT ?`)
882
+ .all(scopeKey, cohort.lane, cohort.sourceId, limit).reverse()
883
+ .map(row => ({ questionId: row.question_id, question: JSON.parse(row.question_json) }));
884
+ return deepFreeze({ grant, proposals, questions });
885
+ }
886
+ counts() {
887
+ const versions = this.#database.prepare('SELECT COUNT(*) AS count FROM evaluation_attention_tuning_versions').get();
888
+ const events = this.#database.prepare('SELECT COUNT(*) AS count FROM evaluation_attention_tuning_events').get();
889
+ return { versions: versions.count, events: events.count };
890
+ }
891
+ }
892
+ //# sourceMappingURL=attention-tuning.js.map