@hegemonart/get-design-done 1.22.0 → 1.23.5
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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +112 -0
- package/package.json +2 -1
- package/reference/output-contracts/planner-decision.schema.json +94 -0
- package/reference/output-contracts/verifier-decision.schema.json +66 -0
- package/scripts/lib/adaptive-mode.cjs +170 -0
- package/scripts/lib/audit-aggregator/index.cjs +219 -0
- package/scripts/lib/bandit-router.cjs +368 -0
- package/scripts/lib/design-solidify.mjs +265 -0
- package/scripts/lib/design-tokens/_js-harness.cjs +66 -0
- package/scripts/lib/design-tokens/css-vars.cjs +55 -0
- package/scripts/lib/design-tokens/figma.cjs +121 -0
- package/scripts/lib/design-tokens/index.cjs +100 -0
- package/scripts/lib/design-tokens/js-const.cjs +107 -0
- package/scripts/lib/design-tokens/tailwind.cjs +98 -0
- package/scripts/lib/domain-primitives/anti-patterns.cjs +66 -0
- package/scripts/lib/domain-primitives/nng.cjs +136 -0
- package/scripts/lib/domain-primitives/wcag.cjs +166 -0
- package/scripts/lib/hedge-ensemble.cjs +217 -0
- package/scripts/lib/mmr-rerank.cjs +154 -0
- package/scripts/lib/parse-contract.cjs +168 -0
- package/scripts/lib/reference-resolver.cjs +184 -0
- package/scripts/lib/touches-analyzer/index.cjs +201 -0
- package/scripts/lib/touches-pattern-miner.cjs +195 -0
- package/scripts/lib/visual-baseline/diff.cjs +137 -0
- package/scripts/lib/visual-baseline/index.cjs +139 -0
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* audit-aggregator/index.cjs — dedup + score + rank findings from N
|
|
3
|
+
* audit-agents (Plan 23-04).
|
|
4
|
+
*
|
|
5
|
+
* Replaces the prompt-only "trust the agent's score" pattern with a
|
|
6
|
+
* deterministic scoring + dedup function that downstream tooling
|
|
7
|
+
* (`/gdd:audit`, `/gdd:reflect`) can rely on.
|
|
8
|
+
*
|
|
9
|
+
* Dedup key: `${lowercased(normalizePath(file))}::${line ?? 0}::${rule_id}`.
|
|
10
|
+
* Survivor selection on collision:
|
|
11
|
+
* 1. higher confidence wins
|
|
12
|
+
* 2. tie → higher severity (P0 > P1 > P2 > P3)
|
|
13
|
+
* 3. tie → lexicographically earliest agent
|
|
14
|
+
* 4. tie → first-seen
|
|
15
|
+
*
|
|
16
|
+
* Score = severityWeight(severity) * confidence.
|
|
17
|
+
*
|
|
18
|
+
* No external deps. CommonJS to match the rest of scripts/lib/.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
'use strict';
|
|
22
|
+
|
|
23
|
+
const SEVERITY_RANK = { P0: 4, P1: 3, P2: 2, P3: 1 };
|
|
24
|
+
const DEFAULT_WEIGHTS = Object.freeze({ P0: 8, P1: 4, P2: 2, P3: 1 });
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @typedef {Object} Finding
|
|
28
|
+
* @property {string} file
|
|
29
|
+
* @property {number} [line]
|
|
30
|
+
* @property {string} rule_id
|
|
31
|
+
* @property {'P0'|'P1'|'P2'|'P3'} severity
|
|
32
|
+
* @property {string} summary
|
|
33
|
+
* @property {string} [evidence]
|
|
34
|
+
* @property {string} [agent]
|
|
35
|
+
* @property {number} [confidence]
|
|
36
|
+
* @property {string[]} [merged_from]
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @typedef {Object} AggregateResult
|
|
41
|
+
* @property {Finding[]} findings
|
|
42
|
+
* @property {Object<string, number>} byRule
|
|
43
|
+
* @property {Object<string, number>} bySeverity
|
|
44
|
+
* @property {Object<string, number>} byFile
|
|
45
|
+
* @property {number} total
|
|
46
|
+
* @property {number} duplicates
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @typedef {Object} AggregateOptions
|
|
51
|
+
* @property {number} [topN]
|
|
52
|
+
* @property {Object<string, number>} [severityWeights]
|
|
53
|
+
* @property {(a: Finding, b: Finding) => Finding} [merge]
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
function normalizePath(p) {
|
|
57
|
+
return String(p).replace(/\\/g, '/').toLowerCase();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let _confidenceWarningEmitted = false;
|
|
61
|
+
|
|
62
|
+
function clampConfidence(c) {
|
|
63
|
+
if (c === undefined || c === null) return 1;
|
|
64
|
+
if (typeof c !== 'number' || Number.isNaN(c)) return 1;
|
|
65
|
+
if (c < 0) {
|
|
66
|
+
if (!_confidenceWarningEmitted) {
|
|
67
|
+
process.emitWarning('audit-aggregator: confidence < 0 clamped to 0', 'AuditAggregator');
|
|
68
|
+
_confidenceWarningEmitted = true;
|
|
69
|
+
}
|
|
70
|
+
return 0;
|
|
71
|
+
}
|
|
72
|
+
if (c > 1) {
|
|
73
|
+
if (!_confidenceWarningEmitted) {
|
|
74
|
+
process.emitWarning('audit-aggregator: confidence > 1 clamped to 1', 'AuditAggregator');
|
|
75
|
+
_confidenceWarningEmitted = true;
|
|
76
|
+
}
|
|
77
|
+
return 1;
|
|
78
|
+
}
|
|
79
|
+
return c;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Compute score for a finding.
|
|
84
|
+
*
|
|
85
|
+
* @param {Finding} f
|
|
86
|
+
* @param {Object<string, number>} weights
|
|
87
|
+
* @returns {number}
|
|
88
|
+
*/
|
|
89
|
+
function score(f, weights) {
|
|
90
|
+
const w = (weights && weights[f.severity]) ?? DEFAULT_WEIGHTS[f.severity] ?? 0;
|
|
91
|
+
return w * clampConfidence(f.confidence);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function validateFinding(f, idx) {
|
|
95
|
+
if (!f || typeof f !== 'object') {
|
|
96
|
+
throw new TypeError(`audit-aggregator: input[${idx}] is not an object`);
|
|
97
|
+
}
|
|
98
|
+
if (typeof f.file !== 'string' || f.file.length === 0) {
|
|
99
|
+
throw new TypeError(`audit-aggregator: input[${idx}].file is required (non-empty string)`);
|
|
100
|
+
}
|
|
101
|
+
if (typeof f.rule_id !== 'string' || f.rule_id.length === 0) {
|
|
102
|
+
throw new TypeError(`audit-aggregator: input[${idx}].rule_id is required (non-empty string)`);
|
|
103
|
+
}
|
|
104
|
+
if (!(f.severity in SEVERITY_RANK)) {
|
|
105
|
+
throw new TypeError(
|
|
106
|
+
`audit-aggregator: input[${idx}].severity must be P0|P1|P2|P3 (got ${JSON.stringify(f.severity)})`,
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function dedupKey(f) {
|
|
112
|
+
return `${normalizePath(f.file)}::${f.line ?? 0}::${f.rule_id}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function defaultMerge(a, b) {
|
|
116
|
+
// Higher confidence wins.
|
|
117
|
+
const ca = clampConfidence(a.confidence);
|
|
118
|
+
const cb = clampConfidence(b.confidence);
|
|
119
|
+
if (ca !== cb) return ca > cb ? a : b;
|
|
120
|
+
// Higher severity wins.
|
|
121
|
+
const ra = SEVERITY_RANK[a.severity];
|
|
122
|
+
const rb = SEVERITY_RANK[b.severity];
|
|
123
|
+
if (ra !== rb) return ra > rb ? a : b;
|
|
124
|
+
// Lexicographic agent.
|
|
125
|
+
const aa = a.agent ?? '';
|
|
126
|
+
const ab = b.agent ?? '';
|
|
127
|
+
if (aa !== ab) return aa < ab ? a : b;
|
|
128
|
+
// First-seen wins (a is by convention the existing entry).
|
|
129
|
+
return a;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Aggregate findings.
|
|
134
|
+
*
|
|
135
|
+
* @param {Finding[]} input
|
|
136
|
+
* @param {AggregateOptions} [opts]
|
|
137
|
+
* @returns {AggregateResult}
|
|
138
|
+
*/
|
|
139
|
+
function aggregate(input, opts = {}) {
|
|
140
|
+
if (!Array.isArray(input)) {
|
|
141
|
+
throw new TypeError('audit-aggregator: input must be an array');
|
|
142
|
+
}
|
|
143
|
+
// Reset the once-per-call warning flag so a second call can warn again.
|
|
144
|
+
_confidenceWarningEmitted = false;
|
|
145
|
+
const merge = typeof opts.merge === 'function' ? opts.merge : defaultMerge;
|
|
146
|
+
const weights = { ...DEFAULT_WEIGHTS, ...(opts.severityWeights || {}) };
|
|
147
|
+
|
|
148
|
+
/** @type {Map<string, Finding>} */
|
|
149
|
+
const byKey = new Map();
|
|
150
|
+
let duplicates = 0;
|
|
151
|
+
for (let i = 0; i < input.length; i++) {
|
|
152
|
+
validateFinding(input[i], i);
|
|
153
|
+
const f = { ...input[i] };
|
|
154
|
+
const key = dedupKey(f);
|
|
155
|
+
if (byKey.has(key)) {
|
|
156
|
+
duplicates += 1;
|
|
157
|
+
const existing = byKey.get(key);
|
|
158
|
+
const winner = merge(existing, f);
|
|
159
|
+
const loser = winner === existing ? f : existing;
|
|
160
|
+
const mergedFrom = new Set(winner.merged_from || []);
|
|
161
|
+
if (existing.agent && existing !== winner) mergedFrom.add(existing.agent);
|
|
162
|
+
if (loser.agent && loser !== winner) mergedFrom.add(loser.agent);
|
|
163
|
+
// Combine prior merged_from too.
|
|
164
|
+
for (const a of (loser.merged_from || [])) mergedFrom.add(a);
|
|
165
|
+
winner.merged_from = Array.from(mergedFrom);
|
|
166
|
+
byKey.set(key, winner);
|
|
167
|
+
} else {
|
|
168
|
+
byKey.set(key, f);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const findings = Array.from(byKey.values()).map((f) => ({ ...f, _score: score(f, weights) }));
|
|
173
|
+
findings.sort((a, b) => {
|
|
174
|
+
if (a._score !== b._score) return b._score - a._score;
|
|
175
|
+
const ra = SEVERITY_RANK[a.severity];
|
|
176
|
+
const rb = SEVERITY_RANK[b.severity];
|
|
177
|
+
if (ra !== rb) return rb - ra;
|
|
178
|
+
if (a.file !== b.file) return a.file < b.file ? -1 : 1;
|
|
179
|
+
return (a.line ?? 0) - (b.line ?? 0);
|
|
180
|
+
});
|
|
181
|
+
// Strip the internal _score field before returning.
|
|
182
|
+
for (const f of findings) delete f._score;
|
|
183
|
+
|
|
184
|
+
const truncated = typeof opts.topN === 'number' && opts.topN >= 0
|
|
185
|
+
? findings.slice(0, opts.topN)
|
|
186
|
+
: findings;
|
|
187
|
+
|
|
188
|
+
/** @type {Record<string, number>} */
|
|
189
|
+
const byRule = {};
|
|
190
|
+
/** @type {Record<string, number>} */
|
|
191
|
+
const bySeverity = { P0: 0, P1: 0, P2: 0, P3: 0 };
|
|
192
|
+
/** @type {Record<string, number>} */
|
|
193
|
+
const byFile = {};
|
|
194
|
+
for (const f of truncated) {
|
|
195
|
+
byRule[f.rule_id] = (byRule[f.rule_id] ?? 0) + 1;
|
|
196
|
+
bySeverity[f.severity] += 1;
|
|
197
|
+
const k = normalizePath(f.file);
|
|
198
|
+
byFile[k] = (byFile[k] ?? 0) + 1;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return {
|
|
202
|
+
findings: truncated,
|
|
203
|
+
byRule,
|
|
204
|
+
bySeverity,
|
|
205
|
+
byFile,
|
|
206
|
+
total: truncated.length,
|
|
207
|
+
duplicates,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
module.exports = {
|
|
212
|
+
aggregate,
|
|
213
|
+
score,
|
|
214
|
+
normalizePath,
|
|
215
|
+
dedupKey,
|
|
216
|
+
defaultMerge,
|
|
217
|
+
DEFAULT_WEIGHTS,
|
|
218
|
+
SEVERITY_RANK,
|
|
219
|
+
};
|
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* bandit-router.cjs — contextual Thompson-sampling bandit over
|
|
3
|
+
* (agent_type, touches_size_bin) → {haiku, sonnet, opus} (Plan 23.5-01).
|
|
4
|
+
*
|
|
5
|
+
* Replaces Phase 10.1's static tier_overrides map when the user opts
|
|
6
|
+
* into adaptive_mode = "full". The static map continues to apply when
|
|
7
|
+
* adaptive_mode = "static" (default).
|
|
8
|
+
*
|
|
9
|
+
* Posterior persistence:
|
|
10
|
+
* .design/telemetry/posterior.json
|
|
11
|
+
* { schema_version: '1.0.0',
|
|
12
|
+
* generated_at: ISO,
|
|
13
|
+
* arms: [{agent, bin, tier, alpha, beta, last_used, count}] }
|
|
14
|
+
*
|
|
15
|
+
* Atomic .tmp + rename. Discounted Thompson via per-arm time-decay
|
|
16
|
+
* factor `rho^days_since_last_use` applied at sample time, not stored.
|
|
17
|
+
*
|
|
18
|
+
* Reward computation (D-06): two-stage lexicographic
|
|
19
|
+
* if !solidify_pass: reward = 0
|
|
20
|
+
* elif user_undo_in_session: reward = 0
|
|
21
|
+
* else: reward = 1 - lambda * normalize(cost + epsilon * wall_time)
|
|
22
|
+
*
|
|
23
|
+
* No external deps. CommonJS to match scripts/lib/ siblings.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
'use strict';
|
|
27
|
+
|
|
28
|
+
const fs = require('node:fs');
|
|
29
|
+
const path = require('node:path');
|
|
30
|
+
|
|
31
|
+
const DEFAULT_POSTERIOR_PATH = '.design/telemetry/posterior.json';
|
|
32
|
+
const SCHEMA_VERSION = '1.0.0';
|
|
33
|
+
|
|
34
|
+
// Decay factor — 60-day half-life.
|
|
35
|
+
const DEFAULT_DECAY = 0.988;
|
|
36
|
+
|
|
37
|
+
// Informed prior strengths per tier (D-03). alpha + beta ≈ 10 → 5–10
|
|
38
|
+
// local samples will visibly shift the posterior.
|
|
39
|
+
const TIER_PRIOR = Object.freeze({
|
|
40
|
+
haiku: 0.6,
|
|
41
|
+
sonnet: 0.8,
|
|
42
|
+
opus: 0.85,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const PRIOR_STRENGTH = 10;
|
|
46
|
+
const DEFAULT_TIERS = Object.freeze(['haiku', 'sonnet', 'opus']);
|
|
47
|
+
|
|
48
|
+
const DEFAULT_PRIORS = Object.freeze({
|
|
49
|
+
decay: DEFAULT_DECAY,
|
|
50
|
+
strength: PRIOR_STRENGTH,
|
|
51
|
+
tiers: DEFAULT_TIERS,
|
|
52
|
+
perTier: TIER_PRIOR,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const TOUCHES_BINS = Object.freeze([
|
|
56
|
+
{ name: 'tiny', max: 4 },
|
|
57
|
+
{ name: 'small', max: 15 },
|
|
58
|
+
{ name: 'medium', max: 50 },
|
|
59
|
+
{ name: 'large', max: Infinity },
|
|
60
|
+
]);
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Resolve a touches-size bin from a glob count.
|
|
64
|
+
* @param {number} globCount
|
|
65
|
+
* @returns {string}
|
|
66
|
+
*/
|
|
67
|
+
function binForGlobCount(globCount) {
|
|
68
|
+
for (const b of TOUCHES_BINS) {
|
|
69
|
+
if (globCount <= b.max) return b.name;
|
|
70
|
+
}
|
|
71
|
+
return 'large';
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Load the posterior file or return a fresh envelope.
|
|
76
|
+
* @param {{baseDir?: string, posteriorPath?: string}} [opts]
|
|
77
|
+
* @returns {{schema_version: string, generated_at: string, arms: object[]}}
|
|
78
|
+
*/
|
|
79
|
+
function loadPosterior(opts = {}) {
|
|
80
|
+
const p = resolvePath(opts);
|
|
81
|
+
if (!fs.existsSync(p)) {
|
|
82
|
+
return { schema_version: SCHEMA_VERSION, generated_at: new Date().toISOString(), arms: [] };
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
const data = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
86
|
+
if (!Array.isArray(data.arms)) {
|
|
87
|
+
data.arms = [];
|
|
88
|
+
}
|
|
89
|
+
return data;
|
|
90
|
+
} catch {
|
|
91
|
+
return { schema_version: SCHEMA_VERSION, generated_at: new Date().toISOString(), arms: [] };
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function resolvePath(opts = {}) {
|
|
96
|
+
if (opts.posteriorPath) {
|
|
97
|
+
return path.isAbsolute(opts.posteriorPath)
|
|
98
|
+
? opts.posteriorPath
|
|
99
|
+
: path.resolve(opts.baseDir ?? process.cwd(), opts.posteriorPath);
|
|
100
|
+
}
|
|
101
|
+
return path.resolve(opts.baseDir ?? process.cwd(), DEFAULT_POSTERIOR_PATH);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Persist the posterior atomically.
|
|
106
|
+
* @param {object} posterior
|
|
107
|
+
* @param {{baseDir?: string, posteriorPath?: string}} [opts]
|
|
108
|
+
* @returns {string} absolute path written
|
|
109
|
+
*/
|
|
110
|
+
function savePosterior(posterior, opts = {}) {
|
|
111
|
+
const p = resolvePath(opts);
|
|
112
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
113
|
+
posterior.generated_at = new Date().toISOString();
|
|
114
|
+
const tmp = p + '.tmp';
|
|
115
|
+
fs.writeFileSync(tmp, JSON.stringify(posterior, null, 2));
|
|
116
|
+
fs.renameSync(tmp, p);
|
|
117
|
+
return p;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Reset the posterior — deletes the file. Next call rebootstraps.
|
|
122
|
+
*
|
|
123
|
+
* @param {{baseDir?: string, posteriorPath?: string, reason?: string}} [opts]
|
|
124
|
+
* @returns {{deleted: boolean, path: string, reason?: string}}
|
|
125
|
+
*/
|
|
126
|
+
function reset(opts = {}) {
|
|
127
|
+
const p = resolvePath(opts);
|
|
128
|
+
const existed = fs.existsSync(p);
|
|
129
|
+
if (existed) fs.unlinkSync(p);
|
|
130
|
+
return { deleted: existed, path: p, reason: opts.reason };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function priorFor(tier, strength) {
|
|
134
|
+
const prior = TIER_PRIOR[tier];
|
|
135
|
+
if (prior === undefined) {
|
|
136
|
+
return { alpha: strength / 2, beta: strength / 2 };
|
|
137
|
+
}
|
|
138
|
+
return {
|
|
139
|
+
alpha: 2 + prior * (strength - 4),
|
|
140
|
+
beta: 2 + (1 - prior) * (strength - 4),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function findArm(arms, agent, bin, tier) {
|
|
145
|
+
return arms.find((a) => a.agent === agent && a.bin === bin && a.tier === tier);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function ensureArm(posterior, agent, bin, tier, strength) {
|
|
149
|
+
let arm = findArm(posterior.arms, agent, bin, tier);
|
|
150
|
+
if (arm) return arm;
|
|
151
|
+
const { alpha, beta } = priorFor(tier, strength);
|
|
152
|
+
arm = {
|
|
153
|
+
agent,
|
|
154
|
+
bin,
|
|
155
|
+
tier,
|
|
156
|
+
alpha,
|
|
157
|
+
beta,
|
|
158
|
+
last_used: null,
|
|
159
|
+
count: 0,
|
|
160
|
+
};
|
|
161
|
+
posterior.arms.push(arm);
|
|
162
|
+
return arm;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Sample from a Beta(alpha, beta) distribution via the gamma-ratio
|
|
167
|
+
* trick: X = G(alpha, 1) / (G(alpha, 1) + G(beta, 1)).
|
|
168
|
+
*
|
|
169
|
+
* Gamma(k, 1) sampled via Marsaglia-Tsang (k>=1) or
|
|
170
|
+
* Ahrens-Dieter (k<1). For our priors alpha/beta ∈ [2, ~10] so the
|
|
171
|
+
* k>=1 branch dominates.
|
|
172
|
+
*
|
|
173
|
+
* @param {number} alpha
|
|
174
|
+
* @param {number} beta
|
|
175
|
+
* @returns {number}
|
|
176
|
+
*/
|
|
177
|
+
function sampleBeta(alpha, beta) {
|
|
178
|
+
if (alpha <= 0 || beta <= 0) return 0.5;
|
|
179
|
+
const x = sampleGamma(alpha);
|
|
180
|
+
const y = sampleGamma(beta);
|
|
181
|
+
if (x + y === 0) return 0.5;
|
|
182
|
+
return x / (x + y);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Math.random() is intentional here. Bandit sampling needs uniform
|
|
186
|
+
// noise, not cryptographic randomness — using crypto + arithmetic is
|
|
187
|
+
// what CodeQL js/biased-cryptographic-random flags. Math.random is
|
|
188
|
+
// uniform-enough for Thompson sampling; security is not a concern.
|
|
189
|
+
function randn() {
|
|
190
|
+
const u1 = Math.random() || 1e-12; // avoid log(0)
|
|
191
|
+
const u2 = Math.random();
|
|
192
|
+
return Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function rand01() {
|
|
196
|
+
return Math.random();
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function sampleGamma(k) {
|
|
200
|
+
if (k < 1) {
|
|
201
|
+
const u = rand01();
|
|
202
|
+
return sampleGamma(k + 1) * Math.pow(u, 1 / k);
|
|
203
|
+
}
|
|
204
|
+
const d = k - 1 / 3;
|
|
205
|
+
const c = 1 / Math.sqrt(9 * d);
|
|
206
|
+
// Marsaglia-Tsang.
|
|
207
|
+
// Loop until accepted; bounded iterations for safety.
|
|
208
|
+
for (let i = 0; i < 1000; i++) {
|
|
209
|
+
const x = randn();
|
|
210
|
+
const v = Math.pow(1 + c * x, 3);
|
|
211
|
+
if (v <= 0) continue;
|
|
212
|
+
const u = rand01();
|
|
213
|
+
if (u < 1 - 0.0331 * Math.pow(x, 4)) return d * v;
|
|
214
|
+
if (Math.log(u) < 0.5 * x * x + d * (1 - v + Math.log(v))) return d * v;
|
|
215
|
+
}
|
|
216
|
+
return d; // fallback to mean
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Apply discounted decay to an arm in place. Returns the (alpha, beta)
|
|
221
|
+
* after decay — does NOT persist.
|
|
222
|
+
*
|
|
223
|
+
* @param {object} arm
|
|
224
|
+
* @param {{decay?: number, now?: Date}} [opts]
|
|
225
|
+
* @returns {{alpha: number, beta: number}}
|
|
226
|
+
*/
|
|
227
|
+
function decayArm(arm, opts = {}) {
|
|
228
|
+
const decay = opts.decay ?? DEFAULT_DECAY;
|
|
229
|
+
const now = opts.now ?? new Date();
|
|
230
|
+
if (!arm.last_used) return { alpha: arm.alpha, beta: arm.beta };
|
|
231
|
+
const lastDate = new Date(arm.last_used);
|
|
232
|
+
const days = Math.max(0, (now.getTime() - lastDate.getTime()) / 86_400_000);
|
|
233
|
+
const factor = Math.pow(decay, days);
|
|
234
|
+
// Decay shrinks both α and β toward the prior. We never go below the
|
|
235
|
+
// initial prior strength — caller can rebuild a fresh prior via reset().
|
|
236
|
+
const { alpha: pa, beta: pb } = priorFor(arm.tier, opts.strength ?? PRIOR_STRENGTH);
|
|
237
|
+
return {
|
|
238
|
+
alpha: pa + factor * Math.max(0, arm.alpha - pa),
|
|
239
|
+
beta: pb + factor * Math.max(0, arm.beta - pb),
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Pull an arm — sample each tier's Beta posterior (with decay) and
|
|
245
|
+
* pick the argmax. Persists the chosen arm's `last_used` + `count`
|
|
246
|
+
* counters. Bandit pull does NOT update the success/fail counters —
|
|
247
|
+
* that happens in `update()` once the outcome is known.
|
|
248
|
+
*
|
|
249
|
+
* @param {{agent: string, bin: string, tiers?: string[], baseDir?: string, posteriorPath?: string, decay?: number, strength?: number, now?: Date}} input
|
|
250
|
+
* @returns {{tier: string, samples: Record<string, number>, posteriorPath: string}}
|
|
251
|
+
*/
|
|
252
|
+
function pull(input) {
|
|
253
|
+
if (!input || typeof input.agent !== 'string' || input.agent.length === 0) {
|
|
254
|
+
throw new TypeError('bandit-router.pull: agent (string) required');
|
|
255
|
+
}
|
|
256
|
+
if (typeof input.bin !== 'string' || input.bin.length === 0) {
|
|
257
|
+
throw new TypeError('bandit-router.pull: bin (string) required');
|
|
258
|
+
}
|
|
259
|
+
const tiers = input.tiers ?? DEFAULT_TIERS;
|
|
260
|
+
const strength = input.strength ?? PRIOR_STRENGTH;
|
|
261
|
+
const now = input.now ?? new Date();
|
|
262
|
+
|
|
263
|
+
const posterior = loadPosterior(input);
|
|
264
|
+
/** @type {Record<string, number>} */
|
|
265
|
+
const samples = {};
|
|
266
|
+
let bestTier = tiers[0];
|
|
267
|
+
let bestSample = -1;
|
|
268
|
+
for (const tier of tiers) {
|
|
269
|
+
const arm = ensureArm(posterior, input.agent, input.bin, tier, strength);
|
|
270
|
+
const decayed = decayArm(arm, { decay: input.decay, now, strength });
|
|
271
|
+
const s = sampleBeta(decayed.alpha, decayed.beta);
|
|
272
|
+
samples[tier] = s;
|
|
273
|
+
if (s > bestSample) {
|
|
274
|
+
bestSample = s;
|
|
275
|
+
bestTier = tier;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
// Bump counters on the chosen arm.
|
|
279
|
+
const chosen = ensureArm(posterior, input.agent, input.bin, bestTier, strength);
|
|
280
|
+
chosen.last_used = now.toISOString();
|
|
281
|
+
chosen.count += 1;
|
|
282
|
+
const written = savePosterior(posterior, input);
|
|
283
|
+
return { tier: bestTier, samples, posteriorPath: written };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Update the posterior with a reward signal. Reward is applied as a
|
|
288
|
+
* Bernoulli observation: success → α += reward, β += (1 - reward).
|
|
289
|
+
*
|
|
290
|
+
* @param {{agent: string, bin: string, tier: string, reward: number, baseDir?: string, posteriorPath?: string, strength?: number}} input
|
|
291
|
+
* @returns {{alpha: number, beta: number, posteriorPath: string}}
|
|
292
|
+
*/
|
|
293
|
+
function update(input) {
|
|
294
|
+
if (!input) throw new TypeError('bandit-router.update: input required');
|
|
295
|
+
for (const k of ['agent', 'bin', 'tier']) {
|
|
296
|
+
if (typeof input[k] !== 'string' || input[k].length === 0) {
|
|
297
|
+
throw new TypeError(`bandit-router.update: ${k} (string) required`);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
if (typeof input.reward !== 'number' || Number.isNaN(input.reward)) {
|
|
301
|
+
throw new TypeError('bandit-router.update: reward (number) required');
|
|
302
|
+
}
|
|
303
|
+
// Reward must be in [0, 1].
|
|
304
|
+
const r = Math.min(1, Math.max(0, input.reward));
|
|
305
|
+
const posterior = loadPosterior(input);
|
|
306
|
+
const arm = ensureArm(posterior, input.agent, input.bin, input.tier, input.strength ?? PRIOR_STRENGTH);
|
|
307
|
+
arm.alpha += r;
|
|
308
|
+
arm.beta += 1 - r;
|
|
309
|
+
const p = savePosterior(posterior, input);
|
|
310
|
+
return { alpha: arm.alpha, beta: arm.beta, posteriorPath: p };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Two-stage lexicographic reward (D-06).
|
|
315
|
+
*
|
|
316
|
+
* if !solidify_pass: 0
|
|
317
|
+
* elif user_undo_in_session: 0
|
|
318
|
+
* else: 1 - lambda * normalize(cost_usd + epsilon * wall_time_ms / 1000)
|
|
319
|
+
*
|
|
320
|
+
* Cost is normalised via the supplied `costNormalizer` (defaults to
|
|
321
|
+
* mapping [0, 5 USD] → [0, 1], capped at 1).
|
|
322
|
+
*
|
|
323
|
+
* @param {{
|
|
324
|
+
* solidify_pass: boolean,
|
|
325
|
+
* user_undo_in_session?: boolean,
|
|
326
|
+
* cost_usd?: number,
|
|
327
|
+
* wall_time_ms?: number,
|
|
328
|
+
* lambda?: number,
|
|
329
|
+
* epsilon?: number,
|
|
330
|
+
* costNormalizer?: (n: number) => number,
|
|
331
|
+
* }} input
|
|
332
|
+
* @returns {number} reward in [0, 1]
|
|
333
|
+
*/
|
|
334
|
+
function computeReward(input) {
|
|
335
|
+
if (!input || typeof input !== 'object') return 0;
|
|
336
|
+
if (!input.solidify_pass) return 0;
|
|
337
|
+
if (input.user_undo_in_session === true) return 0;
|
|
338
|
+
const lambda = typeof input.lambda === 'number' ? input.lambda : 0.3;
|
|
339
|
+
const epsilon = typeof input.epsilon === 'number' ? input.epsilon : 0.05;
|
|
340
|
+
const norm =
|
|
341
|
+
typeof input.costNormalizer === 'function'
|
|
342
|
+
? input.costNormalizer
|
|
343
|
+
: (n) => Math.min(1, Math.max(0, n / 5));
|
|
344
|
+
const wall = (typeof input.wall_time_ms === 'number' ? input.wall_time_ms : 0) / 1000;
|
|
345
|
+
const raw = (typeof input.cost_usd === 'number' ? input.cost_usd : 0) + epsilon * wall;
|
|
346
|
+
const reward = 1 - lambda * norm(raw);
|
|
347
|
+
return Math.min(1, Math.max(0, reward));
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
module.exports = {
|
|
351
|
+
pull,
|
|
352
|
+
update,
|
|
353
|
+
reset,
|
|
354
|
+
loadPosterior,
|
|
355
|
+
savePosterior,
|
|
356
|
+
computeReward,
|
|
357
|
+
binForGlobCount,
|
|
358
|
+
decayArm,
|
|
359
|
+
sampleBeta,
|
|
360
|
+
priorFor,
|
|
361
|
+
DEFAULT_PRIORS,
|
|
362
|
+
DEFAULT_TIERS,
|
|
363
|
+
TIER_PRIOR,
|
|
364
|
+
PRIOR_STRENGTH,
|
|
365
|
+
TOUCHES_BINS,
|
|
366
|
+
DEFAULT_POSTERIOR_PATH,
|
|
367
|
+
SCHEMA_VERSION,
|
|
368
|
+
};
|