@chdlabs/my-shield 0.1.3 → 0.3.0
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 +245 -113
- package/models/default-weights.json +682 -0
- package/package.json +19 -4
- package/src/admin/otpGate.js +370 -0
- package/src/biometrics/index.js +10 -0
- package/src/biometrics/minirocket.js +213 -0
- package/src/biometrics/ridge.js +144 -0
- package/src/client/collector.js +136 -0
- package/src/client/index.js +68 -0
- package/src/client/shieldClient.js +118 -0
- package/src/core/ipResolver.js +225 -0
- package/src/index.d.ts +181 -0
- package/src/index.js +138 -5
- package/src/middleware/behavioralGuard.js +304 -0
- package/src/middleware/honeypot.js +6 -4
- package/src/middleware/notFoundCollector.js +6 -4
- package/src/middleware/shieldGuard.js +3 -0
- package/src/storage/BaseAdapter.js +58 -0
- package/src/storage/CallbackStorageAdapter.js +69 -0
- package/src/storage/FileStorageAdapter.js +174 -0
- package/src/storage/MemoryAdapter.js +56 -0
- package/src/storage/index.js +53 -0
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shield OTP Admin Verification Gate
|
|
3
|
+
*
|
|
4
|
+
* Provides a zero-dependency MFA challenge gate for the Shield Admin Dashboard
|
|
5
|
+
* and sensitive governance endpoints. Uses cryptographically secure OTP generation,
|
|
6
|
+
* timing-safe HMAC validation, rate limiting, and a self-contained dark-mode challenge UI.
|
|
7
|
+
*
|
|
8
|
+
* @author ChdLabs
|
|
9
|
+
* @license MIT
|
|
10
|
+
*/
|
|
11
|
+
import crypto from 'node:crypto';
|
|
12
|
+
|
|
13
|
+
export class OTPGate {
|
|
14
|
+
/**
|
|
15
|
+
* @param {Object} [options={}]
|
|
16
|
+
* @param {string} [options.adminEmail]
|
|
17
|
+
* @param {string} [options.jwtSecret] Secret for HMAC signing (auto-generated if omitted)
|
|
18
|
+
* @param {string} [options.cookieName='shield_session']
|
|
19
|
+
* @param {number} [options.codeTtlMs=300000] 5 minutes default
|
|
20
|
+
* @param {number} [options.sessionTtlMs=86400000] 24 hours default
|
|
21
|
+
* @param {number} [options.maxAttempts=5]
|
|
22
|
+
* @param {Function} [options.sendOtp] Async delivery function: async (email, code) => void
|
|
23
|
+
* @param {Object} [options.logger]
|
|
24
|
+
*/
|
|
25
|
+
constructor(options = {}) {
|
|
26
|
+
this.adminEmail = options.adminEmail || 'admin@localhost';
|
|
27
|
+
this.secret = options.jwtSecret || options.secret || crypto.randomBytes(32).toString('hex');
|
|
28
|
+
this.cookieName = options.cookieName || 'shield_session';
|
|
29
|
+
this.codeTtlMs = options.codeTtlMs || 5 * 60 * 1000;
|
|
30
|
+
this.sessionTtlMs = options.sessionTtlMs || 24 * 60 * 60 * 1000;
|
|
31
|
+
this.maxAttempts = Math.max(1, options.maxAttempts || 5);
|
|
32
|
+
this.logger = options.logger || console;
|
|
33
|
+
|
|
34
|
+
// Fallback default sender logs code to console if no mailer provided
|
|
35
|
+
this.sendOtp = options.sendOtp || (async (email, code) => {
|
|
36
|
+
this.logger.info?.(`[SHIELD:OTP] Verification code for ${email}: ${code}`);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// In-memory active challenge store: email -> { code, expiresAt, attempts }
|
|
40
|
+
this.challenges = new Map();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Generate secure 6-digit numeric OTP code
|
|
45
|
+
* @returns {string}
|
|
46
|
+
*/
|
|
47
|
+
generateCode() {
|
|
48
|
+
return crypto.randomInt(100000, 1000000).toString();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Create an HMAC signed session token
|
|
53
|
+
* @param {string} email
|
|
54
|
+
* @returns {string}
|
|
55
|
+
*/
|
|
56
|
+
createSessionToken(email) {
|
|
57
|
+
const expiresAt = Date.now() + this.sessionTtlMs;
|
|
58
|
+
const payload = `${email}:${expiresAt}`;
|
|
59
|
+
const signature = crypto.createHmac('sha256', this.secret).update(payload).digest('hex');
|
|
60
|
+
return Buffer.from(`${payload}:${signature}`).toString('base64url');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Verify session token using timing-safe comparison
|
|
65
|
+
* @param {string} token
|
|
66
|
+
* @returns {{ valid: boolean, email?: string }}
|
|
67
|
+
*/
|
|
68
|
+
verifySessionToken(token) {
|
|
69
|
+
if (!token || typeof token !== 'string') return { valid: false };
|
|
70
|
+
try {
|
|
71
|
+
const raw = Buffer.from(token, 'base64url').toString('utf8');
|
|
72
|
+
const parts = raw.split(':');
|
|
73
|
+
if (parts.length < 3) return { valid: false };
|
|
74
|
+
|
|
75
|
+
const signature = parts.pop();
|
|
76
|
+
const expiresAt = parseInt(parts.pop(), 10);
|
|
77
|
+
const email = parts.join(':');
|
|
78
|
+
|
|
79
|
+
if (!expiresAt || expiresAt < Date.now()) {
|
|
80
|
+
return { valid: false };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const payload = `${email}:${expiresAt}`;
|
|
84
|
+
const expected = crypto.createHmac('sha256', this.secret).update(payload).digest('hex');
|
|
85
|
+
|
|
86
|
+
const sigBuf = Buffer.from(signature, 'hex');
|
|
87
|
+
const expBuf = Buffer.from(expected, 'hex');
|
|
88
|
+
|
|
89
|
+
if (sigBuf.length !== expBuf.length || !crypto.timingSafeEqual(sigBuf, expBuf)) {
|
|
90
|
+
return { valid: false };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return { valid: true, email };
|
|
94
|
+
} catch {
|
|
95
|
+
return { valid: false };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Parse cookies from request header
|
|
101
|
+
* @param {Object} req
|
|
102
|
+
* @returns {Record<string, string>}
|
|
103
|
+
*/
|
|
104
|
+
parseCookies(req) {
|
|
105
|
+
const header = req.headers?.cookie;
|
|
106
|
+
if (!header) return {};
|
|
107
|
+
const cookies = {};
|
|
108
|
+
for (const pair of header.split(';')) {
|
|
109
|
+
const [k, ...v] = pair.trim().split('=');
|
|
110
|
+
if (k) cookies[k] = decodeURIComponent(v.join('='));
|
|
111
|
+
}
|
|
112
|
+
return cookies;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Render self-contained zero-dependency dark-mode HTML challenge page
|
|
117
|
+
*/
|
|
118
|
+
renderChallengeHtml(req, errorMessage = '') {
|
|
119
|
+
const baseUrl = req.baseUrl || '';
|
|
120
|
+
return `<!DOCTYPE html>
|
|
121
|
+
<html lang="en">
|
|
122
|
+
<head>
|
|
123
|
+
<meta charset="UTF-8">
|
|
124
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
125
|
+
<title>Shield Admin Authentication</title>
|
|
126
|
+
<style>
|
|
127
|
+
:root {
|
|
128
|
+
--bg: #090d16;
|
|
129
|
+
--surface: #131b2e;
|
|
130
|
+
--border: #1f2d4d;
|
|
131
|
+
--primary: #3b82f6;
|
|
132
|
+
--primary-hover: #2563eb;
|
|
133
|
+
--text: #f8fafc;
|
|
134
|
+
--muted: #94a3b8;
|
|
135
|
+
--danger: #ef4444;
|
|
136
|
+
--success: #10b981;
|
|
137
|
+
}
|
|
138
|
+
* { box-sizing: border-box; margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
|
|
139
|
+
body {
|
|
140
|
+
background-color: var(--bg);
|
|
141
|
+
color: var(--text);
|
|
142
|
+
display: flex;
|
|
143
|
+
align-items: center;
|
|
144
|
+
justify-content: center;
|
|
145
|
+
min-height: 100vh;
|
|
146
|
+
padding: 1.5rem;
|
|
147
|
+
}
|
|
148
|
+
.card {
|
|
149
|
+
background: var(--surface);
|
|
150
|
+
border: 1px solid var(--border);
|
|
151
|
+
border-radius: 12px;
|
|
152
|
+
padding: 2.5rem;
|
|
153
|
+
width: 100%;
|
|
154
|
+
max-width: 420px;
|
|
155
|
+
box-shadow: 0 20px 40px rgba(0,0,0,0.4);
|
|
156
|
+
}
|
|
157
|
+
.header { text-align: center; margin-bottom: 2rem; }
|
|
158
|
+
.logo { font-size: 2.5rem; margin-bottom: 0.5rem; }
|
|
159
|
+
h1 { font-size: 1.5rem; font-weight: 600; margin-bottom: 0.25rem; }
|
|
160
|
+
p.subtitle { color: var(--muted); font-size: 0.875rem; }
|
|
161
|
+
.alert {
|
|
162
|
+
background: rgba(239, 68, 68, 0.15);
|
|
163
|
+
border: 1px solid var(--danger);
|
|
164
|
+
color: #fca5a5;
|
|
165
|
+
padding: 0.75rem 1rem;
|
|
166
|
+
border-radius: 6px;
|
|
167
|
+
font-size: 0.875rem;
|
|
168
|
+
margin-bottom: 1.25rem;
|
|
169
|
+
}
|
|
170
|
+
.success-box {
|
|
171
|
+
background: rgba(16, 185, 129, 0.15);
|
|
172
|
+
border: 1px solid var(--success);
|
|
173
|
+
color: #6ee7b7;
|
|
174
|
+
padding: 0.75rem 1rem;
|
|
175
|
+
border-radius: 6px;
|
|
176
|
+
font-size: 0.875rem;
|
|
177
|
+
margin-bottom: 1.25rem;
|
|
178
|
+
}
|
|
179
|
+
.form-group { margin-bottom: 1.25rem; }
|
|
180
|
+
label { display: block; font-size: 0.875rem; font-weight: 500; margin-bottom: 0.5rem; color: var(--muted); }
|
|
181
|
+
input[type="text"] {
|
|
182
|
+
width: 100%;
|
|
183
|
+
background: #0b1120;
|
|
184
|
+
border: 1px solid var(--border);
|
|
185
|
+
color: var(--text);
|
|
186
|
+
padding: 0.75rem 1rem;
|
|
187
|
+
border-radius: 6px;
|
|
188
|
+
font-size: 1.125rem;
|
|
189
|
+
letter-spacing: 0.25em;
|
|
190
|
+
text-align: center;
|
|
191
|
+
outline: none;
|
|
192
|
+
transition: border-color 0.2s;
|
|
193
|
+
}
|
|
194
|
+
input[type="text"]:focus { border-color: var(--primary); }
|
|
195
|
+
button {
|
|
196
|
+
width: 100%;
|
|
197
|
+
background: var(--primary);
|
|
198
|
+
color: white;
|
|
199
|
+
border: none;
|
|
200
|
+
padding: 0.75rem;
|
|
201
|
+
border-radius: 6px;
|
|
202
|
+
font-size: 0.95rem;
|
|
203
|
+
font-weight: 600;
|
|
204
|
+
cursor: pointer;
|
|
205
|
+
transition: background 0.2s;
|
|
206
|
+
}
|
|
207
|
+
button:hover { background: var(--primary-hover); }
|
|
208
|
+
.actions { display: flex; justify-content: space-between; margin-top: 1rem; }
|
|
209
|
+
.actions button.secondary {
|
|
210
|
+
background: transparent;
|
|
211
|
+
border: 1px solid var(--border);
|
|
212
|
+
color: var(--muted);
|
|
213
|
+
font-weight: normal;
|
|
214
|
+
}
|
|
215
|
+
.actions button.secondary:hover { color: var(--text); border-color: var(--text); background: transparent; }
|
|
216
|
+
</style>
|
|
217
|
+
</head>
|
|
218
|
+
<body>
|
|
219
|
+
<div class="card">
|
|
220
|
+
<div class="header">
|
|
221
|
+
<div class="logo">🛡️</div>
|
|
222
|
+
<h1>Shield Security Gate</h1>
|
|
223
|
+
<p class="subtitle">Enter the 6-digit authentication code sent to administrator.</p>
|
|
224
|
+
</div>
|
|
225
|
+
|
|
226
|
+
${errorMessage ? `<div class="alert">${errorMessage}</div>` : ''}
|
|
227
|
+
|
|
228
|
+
<form method="POST" action="${baseUrl}/__shield_verify">
|
|
229
|
+
<div class="form-group">
|
|
230
|
+
<label for="otp">One-Time Passcode</label>
|
|
231
|
+
<input id="otp" name="code" type="text" maxlength="6" pattern="[0-9]{6}" inputmode="numeric" placeholder="••••••" required autofocus autocomplete="one-time-code" />
|
|
232
|
+
</div>
|
|
233
|
+
<button type="submit">Verify & Access Console</button>
|
|
234
|
+
</form>
|
|
235
|
+
|
|
236
|
+
<form method="POST" action="${baseUrl}/__shield_request" style="margin-top: 1rem;">
|
|
237
|
+
<button type="submit" class="secondary">Request New Code</button>
|
|
238
|
+
</form>
|
|
239
|
+
</div>
|
|
240
|
+
</body>
|
|
241
|
+
</html>`;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Express middleware handler
|
|
246
|
+
*/
|
|
247
|
+
middleware() {
|
|
248
|
+
return async (req, res, next) => {
|
|
249
|
+
const cookies = this.parseCookies(req);
|
|
250
|
+
const sessionToken = cookies[this.cookieName] || req.headers?.['x-shield-auth-token'];
|
|
251
|
+
|
|
252
|
+
// 1. Check existing verified session
|
|
253
|
+
if (sessionToken) {
|
|
254
|
+
const verification = this.verifySessionToken(sessionToken);
|
|
255
|
+
if (verification.valid) {
|
|
256
|
+
req.shieldAdmin = { email: verification.email };
|
|
257
|
+
return next();
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const path = req.path || '';
|
|
262
|
+
|
|
263
|
+
// 2. Handle POST Request to send a new code
|
|
264
|
+
if (req.method === 'POST' && path.endsWith('/__shield_request')) {
|
|
265
|
+
const code = this.generateCode();
|
|
266
|
+
this.challenges.set(this.adminEmail, {
|
|
267
|
+
code,
|
|
268
|
+
expiresAt: Date.now() + this.codeTtlMs,
|
|
269
|
+
attempts: 0
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
try {
|
|
273
|
+
await this.sendOtp(this.adminEmail, code);
|
|
274
|
+
} catch (err) {
|
|
275
|
+
this.logger.error?.('[SHIELD:OTP] Error dispatching OTP email:', err.message);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (req.accepts('html')) {
|
|
279
|
+
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
280
|
+
return res.send(this.renderChallengeHtml(req, ''));
|
|
281
|
+
}
|
|
282
|
+
return res.json({ success: true, message: 'Verification code generated' });
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// 3. Handle POST Request to verify code
|
|
286
|
+
if (req.method === 'POST' && path.endsWith('/__shield_verify')) {
|
|
287
|
+
// Parse body if express.urlencoded/json not mounted
|
|
288
|
+
let code = req.body?.code;
|
|
289
|
+
if (!code && typeof req.body === 'string') {
|
|
290
|
+
const match = req.body.match(/code=([^&]+)/);
|
|
291
|
+
if (match) code = decodeURIComponent(match[1]);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const challenge = this.challenges.get(this.adminEmail);
|
|
295
|
+
if (!challenge || Date.now() > challenge.expiresAt) {
|
|
296
|
+
if (req.accepts('html')) {
|
|
297
|
+
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
298
|
+
return res.status(401).send(this.renderChallengeHtml(req, 'Code has expired. Please request a new code.'));
|
|
299
|
+
}
|
|
300
|
+
return res.status(401).json({ error: 'Code has expired', code: 'EXPIRED_CODE' });
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
challenge.attempts = (challenge.attempts || 0) + 1;
|
|
304
|
+
if (challenge.attempts > this.maxAttempts) {
|
|
305
|
+
this.challenges.delete(this.adminEmail);
|
|
306
|
+
if (req.accepts('html')) {
|
|
307
|
+
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
308
|
+
return res.status(403).send(this.renderChallengeHtml(req, 'Too many failed attempts. Locked out.'));
|
|
309
|
+
}
|
|
310
|
+
return res.status(403).json({ error: 'Maximum verification attempts exceeded', code: 'MAX_ATTEMPTS_EXCEEDED' });
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Verify code with timing-safe comparison
|
|
314
|
+
const userCodeBuf = Buffer.from(String(code || '').trim());
|
|
315
|
+
const expectedCodeBuf = Buffer.from(challenge.code);
|
|
316
|
+
|
|
317
|
+
const isMatch = userCodeBuf.length === expectedCodeBuf.length &&
|
|
318
|
+
crypto.timingSafeEqual(userCodeBuf, expectedCodeBuf);
|
|
319
|
+
|
|
320
|
+
if (isMatch) {
|
|
321
|
+
this.challenges.delete(this.adminEmail);
|
|
322
|
+
const token = this.createSessionToken(this.adminEmail);
|
|
323
|
+
|
|
324
|
+
// Set HttpOnly signed cookie
|
|
325
|
+
const isSecure = req.secure || req.headers['x-forwarded-proto'] === 'https';
|
|
326
|
+
const cookieFlags = [
|
|
327
|
+
`${this.cookieName}=${encodeURIComponent(token)}`,
|
|
328
|
+
'Path=/',
|
|
329
|
+
'HttpOnly',
|
|
330
|
+
'SameSite=Lax',
|
|
331
|
+
`Max-Age=${Math.floor(this.sessionTtlMs / 1000)}`
|
|
332
|
+
];
|
|
333
|
+
if (isSecure) cookieFlags.push('Secure');
|
|
334
|
+
|
|
335
|
+
res.setHeader('Set-Cookie', cookieFlags.join('; '));
|
|
336
|
+
|
|
337
|
+
if (req.accepts('html')) {
|
|
338
|
+
const targetUrl = req.baseUrl || '/';
|
|
339
|
+
return res.redirect(targetUrl || '/');
|
|
340
|
+
}
|
|
341
|
+
return res.json({ success: true, token });
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (req.accepts('html')) {
|
|
345
|
+
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
346
|
+
return res.status(401).send(this.renderChallengeHtml(req, 'Invalid verification code. Please try again.'));
|
|
347
|
+
}
|
|
348
|
+
return res.status(401).json({ error: 'Invalid verification code', code: 'INVALID_CODE' });
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// 4. Default unauthenticated presentation
|
|
352
|
+
if (req.accepts('html')) {
|
|
353
|
+
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
354
|
+
return res.status(401).send(this.renderChallengeHtml(req, ''));
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
return res.status(401).json({
|
|
358
|
+
error: 'Shield OTP authentication required',
|
|
359
|
+
code: 'SHIELD_OTP_REQUIRED'
|
|
360
|
+
});
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
export function createOtpGate(options = {}) {
|
|
366
|
+
const gate = new OTPGate(options);
|
|
367
|
+
return gate.middleware();
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
export default createOtpGate;
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/biometrics/minirocket.js
|
|
3
|
+
* Deterministic MiniRocket 1D Transform Engine
|
|
4
|
+
* High-performance, zero-dependency feature extractor using 84 fixed length-9 kernels
|
|
5
|
+
* with binary/ternary weights {-1, 2} and proportion of positive values (PPV).
|
|
6
|
+
*
|
|
7
|
+
* @author ChdLabs
|
|
8
|
+
* @license MIT
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const MR_KERNEL_LENGTH = 9;
|
|
12
|
+
const MR_NUM_KERNELS = 84;
|
|
13
|
+
|
|
14
|
+
export class MiniRocket {
|
|
15
|
+
/**
|
|
16
|
+
* @param {number} [windowLength=128] Length of the input kinematic series
|
|
17
|
+
* @param {number} [numFeatures=500] Number of MiniRocket PPV features to extract
|
|
18
|
+
*/
|
|
19
|
+
constructor(windowLength = 128, numFeatures = 500) {
|
|
20
|
+
this.windowLength = windowLength;
|
|
21
|
+
this.targetFeatures = numFeatures;
|
|
22
|
+
|
|
23
|
+
this.kernelIndices = this._buildIndices();
|
|
24
|
+
this.dilations = this._buildDilations();
|
|
25
|
+
this.featureMap = this._buildFeatureMap();
|
|
26
|
+
this.thresholds = null;
|
|
27
|
+
|
|
28
|
+
// Pre-allocated scratch buffers to eliminate per-inference GC allocations
|
|
29
|
+
this._ca = new Float64Array(this.windowLength);
|
|
30
|
+
this._cg = new Float64Array(MR_KERNEL_LENGTH * this.windowLength);
|
|
31
|
+
this._c = new Float64Array(this.windowLength);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Build all C(9, 3) = 84 combinations of 3 positive kernel indices
|
|
36
|
+
* @private
|
|
37
|
+
*/
|
|
38
|
+
_buildIndices() {
|
|
39
|
+
const indices = new Int32Array(MR_NUM_KERNELS * 3);
|
|
40
|
+
let p = 0;
|
|
41
|
+
for (let a = 0; a < MR_KERNEL_LENGTH; a++) {
|
|
42
|
+
for (let b = a + 1; b < MR_KERNEL_LENGTH; b++) {
|
|
43
|
+
for (let c = b + 1; c < MR_KERNEL_LENGTH; c++) {
|
|
44
|
+
indices[p * 3 + 0] = a;
|
|
45
|
+
indices[p * 3 + 1] = b;
|
|
46
|
+
indices[p * 3 + 2] = c;
|
|
47
|
+
p++;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return indices;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Compute multi-scale dilation schedule
|
|
56
|
+
* @private
|
|
57
|
+
*/
|
|
58
|
+
_buildDilations() {
|
|
59
|
+
const maxExp = Math.log2((this.windowLength - 1) / (MR_KERNEL_LENGTH - 1));
|
|
60
|
+
const maxDilations = Math.min(32, Math.floor(this.targetFeatures / MR_NUM_KERNELS));
|
|
61
|
+
const dilations = [];
|
|
62
|
+
|
|
63
|
+
for (let i = 0; i < maxDilations; i++) {
|
|
64
|
+
const exponent = (i === maxDilations - 1) ? maxExp : (i * maxExp) / (maxDilations - 1);
|
|
65
|
+
const d = Math.floor(Math.pow(2, exponent));
|
|
66
|
+
if (!dilations.includes(d) && d >= 1) {
|
|
67
|
+
dilations.push(d);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return Int32Array.from(dilations);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Map feature indices to kernel index, dilation, and golden ratio quantile
|
|
75
|
+
* @private
|
|
76
|
+
*/
|
|
77
|
+
_buildFeatureMap() {
|
|
78
|
+
const map = [];
|
|
79
|
+
const phi = (Math.sqrt(5) + 1) / 2;
|
|
80
|
+
let count = 0;
|
|
81
|
+
|
|
82
|
+
for (const d of this.dilations) {
|
|
83
|
+
for (let k = 0; k < MR_NUM_KERNELS; k++) {
|
|
84
|
+
if (count >= this.targetFeatures) break;
|
|
85
|
+
map.push({ kernelIdx: k, dilation: d, quantile: (count * phi) % 1.0 });
|
|
86
|
+
count++;
|
|
87
|
+
}
|
|
88
|
+
if (count >= this.targetFeatures) break;
|
|
89
|
+
}
|
|
90
|
+
return map;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Shared prefix sum computation across all kernels sharing dilation d
|
|
95
|
+
* @private
|
|
96
|
+
*/
|
|
97
|
+
_convolveBase(x, d) {
|
|
98
|
+
const L = this.windowLength;
|
|
99
|
+
const padding = Math.floor(((MR_KERNEL_LENGTH - 1) * d) / 2);
|
|
100
|
+
const mid = Math.floor(MR_KERNEL_LENGTH / 2);
|
|
101
|
+
|
|
102
|
+
this._ca.fill(0);
|
|
103
|
+
this._cg.fill(0);
|
|
104
|
+
|
|
105
|
+
for (let i = 0; i < L; i++) {
|
|
106
|
+
this._ca[i] = -x[i];
|
|
107
|
+
this._cg[mid * L + i] = 3.0 * x[i];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
let end = L - padding;
|
|
111
|
+
for (let g = 0; g < mid; g++) {
|
|
112
|
+
const e = Math.min(end, L);
|
|
113
|
+
if (e > 0) {
|
|
114
|
+
const off = L - e;
|
|
115
|
+
for (let j = 0; j < e; j++) {
|
|
116
|
+
this._ca[off + j] -= x[j];
|
|
117
|
+
this._cg[g * L + off + j] = 3.0 * x[j];
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
end += d;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
let start = d;
|
|
124
|
+
for (let g = mid + 1; g < MR_KERNEL_LENGTH; g++) {
|
|
125
|
+
const m = L - start;
|
|
126
|
+
for (let j = 0; j < m; j++) {
|
|
127
|
+
this._ca[j] -= x[start + j];
|
|
128
|
+
this._cg[g * L + j] = 3.0 * x[start + j];
|
|
129
|
+
}
|
|
130
|
+
start += d;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Evaluate kernel output using precomputed prefix sums and 3 index lookups
|
|
136
|
+
* @private
|
|
137
|
+
*/
|
|
138
|
+
_kernelOut(kernelIdx) {
|
|
139
|
+
const L = this.windowLength;
|
|
140
|
+
const i0 = this.kernelIndices[kernelIdx * 3 + 0] * L;
|
|
141
|
+
const i1 = this.kernelIndices[kernelIdx * 3 + 1] * L;
|
|
142
|
+
const i2 = this.kernelIndices[kernelIdx * 3 + 2] * L;
|
|
143
|
+
|
|
144
|
+
for (let i = 0; i < L; i++) {
|
|
145
|
+
this._c[i] = this._ca[i] + this._cg[i0 + i] + this._cg[i1 + i] + this._cg[i2 + i];
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Load pre-computed bias thresholds
|
|
151
|
+
* @param {number[]|Float32Array} thresholdsArray
|
|
152
|
+
*/
|
|
153
|
+
loadThresholds(thresholdsArray) {
|
|
154
|
+
this.thresholds = Float32Array.from(thresholdsArray);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Fit bias thresholds from a collection of training series
|
|
159
|
+
* @param {Array<number[]|Float64Array>} trainingWindows
|
|
160
|
+
*/
|
|
161
|
+
fit(trainingWindows) {
|
|
162
|
+
const N = trainingWindows.length;
|
|
163
|
+
if (N === 0) throw new Error('Training windows cannot be empty.');
|
|
164
|
+
this.thresholds = new Float32Array(this.featureMap.length);
|
|
165
|
+
|
|
166
|
+
for (let f = 0; f < this.featureMap.length; f++) {
|
|
167
|
+
const feat = this.featureMap[f];
|
|
168
|
+
this._convolveBase(trainingWindows[f % N], feat.dilation);
|
|
169
|
+
this._kernelOut(feat.kernelIdx);
|
|
170
|
+
|
|
171
|
+
const sorted = Float64Array.from(this._c).sort();
|
|
172
|
+
const qIdx = Math.min(this.windowLength - 1, Math.floor(feat.quantile * this.windowLength));
|
|
173
|
+
this.thresholds[f] = sorted[qIdx];
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Transform a 1D input series into Proportion of Positive Values (PPV) representation
|
|
179
|
+
* @param {number[]|Float64Array} series Equispaced series of length equal to windowLength
|
|
180
|
+
* @returns {Float32Array} PPV feature vector
|
|
181
|
+
*/
|
|
182
|
+
transform(series) {
|
|
183
|
+
if (!this.thresholds) {
|
|
184
|
+
throw new Error('MiniRocket requires fitted or loaded thresholds.');
|
|
185
|
+
}
|
|
186
|
+
if (series.length !== this.windowLength) {
|
|
187
|
+
throw new Error(`Input series length (${series.length}) must match windowLength (${this.windowLength}).`);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const out = new Float32Array(this.featureMap.length);
|
|
191
|
+
let currentDilation = -1;
|
|
192
|
+
|
|
193
|
+
for (let f = 0; f < this.featureMap.length; f++) {
|
|
194
|
+
const feat = this.featureMap[f];
|
|
195
|
+
|
|
196
|
+
if (feat.dilation !== currentDilation) {
|
|
197
|
+
this._convolveBase(series, feat.dilation);
|
|
198
|
+
currentDilation = feat.dilation;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
this._kernelOut(feat.kernelIdx);
|
|
202
|
+
|
|
203
|
+
const thresh = this.thresholds[f];
|
|
204
|
+
let pos = 0;
|
|
205
|
+
for (let i = 0; i < this.windowLength; i++) {
|
|
206
|
+
if (this._c[i] > thresh) pos++;
|
|
207
|
+
}
|
|
208
|
+
out[f] = pos / this.windowLength;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return out;
|
|
212
|
+
}
|
|
213
|
+
}
|