@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.
- package/README.md +69 -1
- package/lib/attention-owner-questions.d.ts +104 -0
- package/lib/attention-owner-questions.d.ts.map +1 -0
- package/lib/attention-owner-questions.js +179 -0
- package/lib/attention-owner-questions.js.map +1 -0
- package/lib/attention-tuning.d.ts +492 -0
- package/lib/attention-tuning.d.ts.map +1 -0
- package/lib/attention-tuning.js +1044 -0
- package/lib/attention-tuning.js.map +1 -0
- package/lib/benchmark/cli.d.ts.map +1 -1
- package/lib/benchmark/cli.js +14 -11
- package/lib/benchmark/cli.js.map +1 -1
- package/lib/benchmark/corpus.d.ts +13 -2
- package/lib/benchmark/corpus.d.ts.map +1 -1
- package/lib/benchmark/corpus.js +19 -9
- package/lib/benchmark/corpus.js.map +1 -1
- package/lib/benchmark/families-corpus.d.ts +11 -0
- package/lib/benchmark/families-corpus.d.ts.map +1 -0
- package/lib/benchmark/families-corpus.js +133 -0
- package/lib/benchmark/families-corpus.js.map +1 -0
- package/lib/benchmark/holdout-isolation.d.ts +27 -0
- package/lib/benchmark/holdout-isolation.d.ts.map +1 -0
- package/lib/benchmark/holdout-isolation.js +66 -0
- package/lib/benchmark/holdout-isolation.js.map +1 -0
- package/lib/benchmark/holdout.d.ts.map +1 -1
- package/lib/benchmark/holdout.js +3 -0
- package/lib/benchmark/holdout.js.map +1 -1
- package/lib/benchmark/index.d.ts +3 -0
- package/lib/benchmark/index.d.ts.map +1 -1
- package/lib/benchmark/index.js +2 -0
- package/lib/benchmark/index.js.map +1 -1
- package/lib/benchmark/native-skills.d.ts +12 -14
- package/lib/benchmark/native-skills.d.ts.map +1 -1
- package/lib/benchmark/native-skills.js +1 -3
- package/lib/benchmark/native-skills.js.map +1 -1
- package/lib/benchmark/native.d.ts +15 -2
- package/lib/benchmark/native.d.ts.map +1 -1
- package/lib/benchmark/native.js +67 -14
- package/lib/benchmark/native.js.map +1 -1
- package/lib/benchmark/report.d.ts.map +1 -1
- package/lib/benchmark/report.js +98 -8
- package/lib/benchmark/report.js.map +1 -1
- package/lib/benchmark/runner.d.ts.map +1 -1
- package/lib/benchmark/runner.js +5 -0
- package/lib/benchmark/runner.js.map +1 -1
- package/lib/benchmark/strategy-capabilities.d.ts.map +1 -1
- package/lib/benchmark/strategy-capabilities.js +0 -5
- package/lib/benchmark/strategy-capabilities.js.map +1 -1
- package/lib/benchmark/types.d.ts +64 -7
- package/lib/benchmark/types.d.ts.map +1 -1
- package/lib/index.d.ts +8 -1
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +4 -0
- package/lib/index.js.map +1 -1
- package/lib/intake-attention-agreement.d.ts +63 -0
- package/lib/intake-attention-agreement.d.ts.map +1 -0
- package/lib/intake-attention-agreement.js +146 -0
- package/lib/intake-attention-agreement.js.map +1 -0
- package/lib/notification-outcomes.d.ts +56 -0
- package/lib/notification-outcomes.d.ts.map +1 -0
- package/lib/notification-outcomes.js +348 -0
- package/lib/notification-outcomes.js.map +1 -0
- package/lib/service.d.ts +295 -2
- package/lib/service.d.ts.map +1 -1
- package/lib/service.js +965 -5
- package/lib/service.js.map +1 -1
- package/lib/sqlite.d.ts +8 -2
- package/lib/sqlite.d.ts.map +1 -1
- package/lib/sqlite.js +406 -17
- package/lib/sqlite.js.map +1 -1
- package/lib/store.d.ts +13 -2
- package/lib/store.d.ts.map +1 -1
- package/lib/store.js +122 -24
- package/lib/store.js.map +1 -1
- package/lib/types.d.ts +247 -5
- package/lib/types.d.ts.map +1 -1
- package/lib/types.js +11 -0
- package/lib/types.js.map +1 -1
- package/lib/version.d.ts +1 -1
- package/lib/version.js +1 -1
- package/lib/work-item-owner-proof.d.ts +4 -0
- package/lib/work-item-owner-proof.d.ts.map +1 -0
- package/lib/work-item-owner-proof.js +53 -0
- package/lib/work-item-owner-proof.js.map +1 -0
- package/package.json +29 -38
- package/lib/benchmark/super-relay.d.ts +0 -26
- package/lib/benchmark/super-relay.d.ts.map +0 -1
- package/lib/benchmark/super-relay.js +0 -173
- 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
|