@tetherto/mdk-skill 0.0.0 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +133 -0
  3. package/dist/skills/mdk/SKILL.md +114 -0
  4. package/dist/skills/mdk/references/architecture.md +89 -0
  5. package/dist/skills/mdk/references/glossary.md +27 -0
  6. package/dist/skills/mdk/references/mdk-contract.schema.json +138 -0
  7. package/dist/skills/mdk/references/package-index.md +53 -0
  8. package/dist/skills/mdk/references/protocol.md +95 -0
  9. package/dist/skills/mdk-create-app/SKILL.md +231 -0
  10. package/dist/skills/mdk-deployment/SKILL.md +161 -0
  11. package/dist/skills/mdk-gateway-plugin/SKILL.md +192 -0
  12. package/dist/skills/mdk-gateway-plugin/references/controller-patterns.md +99 -0
  13. package/dist/skills/mdk-gateway-plugin/references/plugin-authoring.md +121 -0
  14. package/dist/skills/mdk-publish-security/SKILL.md +177 -0
  15. package/dist/skills/mdk-publish-security/references/vuln-classes.md +151 -0
  16. package/dist/skills/mdk-publish-security/scripts/scan-packages.mjs +544 -0
  17. package/dist/skills/mdk-site-sizing/SKILL.md +160 -0
  18. package/dist/skills/mdk-site-sizing/eval/held-out.json +104 -0
  19. package/dist/skills/mdk-site-sizing/eval/profiles/cap-100devices-2workers-mixed.json +24 -0
  20. package/dist/skills/mdk-site-sizing/eval/profiles/cap-100devices-2workers.json +23 -0
  21. package/dist/skills/mdk-site-sizing/eval/profiles/cap-10devices-1workers.json +23 -0
  22. package/dist/skills/mdk-site-sizing/eval/profiles/cap-15devices-2workers.json +24 -0
  23. package/dist/skills/mdk-site-sizing/eval/profiles/cap-200devices-4workers.json +23 -0
  24. package/dist/skills/mdk-site-sizing/eval/profiles/cap-500devices-4workers.json +23 -0
  25. package/dist/skills/mdk-site-sizing/eval/profiles/cap-50devices-2workers.json +23 -0
  26. package/dist/skills/mdk-site-sizing/references/envelope.json +284 -0
  27. package/dist/skills/mdk-site-sizing/references/envelope.md +42 -0
  28. package/dist/skills/mdk-site-sizing/scripts/compile-envelope.mjs +45 -0
  29. package/dist/skills/mdk-site-sizing/scripts/eval-held-out.mjs +50 -0
  30. package/dist/skills/mdk-site-sizing/scripts/lib.mjs +591 -0
  31. package/dist/skills/mdk-site-sizing/scripts/parse-site.mjs +25 -0
  32. package/dist/skills/mdk-site-sizing/scripts/size-site.mjs +49 -0
  33. package/dist/skills/mdk-ui-component/SKILL.md +207 -0
  34. package/dist/skills/mdk-ui-component/references/page-recipe.md +78 -0
  35. package/dist/skills/mdk-ui-component/references/ui-registry.json +1 -0
  36. package/dist/skills/mdk-worker-plugin/SKILL.md +198 -0
  37. package/dist/skills/mdk-worker-plugin/assets/mdk-contract.template.json +45 -0
  38. package/dist/skills/mdk-worker-plugin/assets/worker-template/README.md +93 -0
  39. package/dist/skills/mdk-worker-plugin/assets/worker-template/mdk-contract.json +26 -0
  40. package/dist/skills/mdk-worker-plugin/assets/worker-template/mock/server.js +93 -0
  41. package/dist/skills/mdk-worker-plugin/assets/worker-template/package.json +23 -0
  42. package/dist/skills/mdk-worker-plugin/assets/worker-template/smoke.config.js +41 -0
  43. package/dist/skills/mdk-worker-plugin/assets/worker-template/src/client.js +47 -0
  44. package/dist/skills/mdk-worker-plugin/assets/worker-template/src/commands/reboot.js +5 -0
  45. package/dist/skills/mdk-worker-plugin/assets/worker-template/src/commands/set-power-mode.js +10 -0
  46. package/dist/skills/mdk-worker-plugin/assets/worker-template/src/telemetry/hashrate-rt.js +5 -0
  47. package/dist/skills/mdk-worker-plugin/assets/worker-template/src/telemetry/power-mode.js +5 -0
  48. package/dist/skills/mdk-worker-plugin/assets/worker-template/src/telemetry/power.js +5 -0
  49. package/dist/skills/mdk-worker-plugin/assets/worker-template/src/telemetry/temperature.js +5 -0
  50. package/dist/skills/mdk-worker-plugin/references/contract-authoring.md +135 -0
  51. package/dist/skills/mdk-worker-plugin/references/device-families.md +96 -0
  52. package/dist/skills/mdk-worker-plugin/references/local-testing.md +134 -0
  53. package/dist/skills/mdk-worker-plugin/references/mdk-contract.schema.json +138 -0
  54. package/dist/skills/mdk-worker-plugin/references/worker-base-api.md +152 -0
  55. package/dist/skills/mdk-worker-plugin/scripts/validate-contract.mjs +129 -0
  56. package/dist/skills/mdk-worker-plugin/scripts/worker-smoke.mjs +256 -0
  57. package/package.json +47 -1
  58. package/src/assemble.mjs +11 -0
  59. package/src/index.d.ts +57 -0
  60. package/src/index.mjs +254 -0
  61. package/src/install.mjs +57 -0
  62. package/src/sources.map.json +34 -0
@@ -0,0 +1,591 @@
1
+ // Shared parse / compile / size / match logic for mdk-site-sizing.
2
+ // Zero-dependency. The CLI wrappers and the held-out eval all import this.
3
+
4
+ export const THRESHOLDS = {
5
+ headroomGreenBelow: 0.7,
6
+ headroomAmberBelow: 1.0,
7
+ actionSubmitP99Ms: 2000,
8
+ steadyCpuPctOfOneCore: 80
9
+ }
10
+
11
+ export const FAMILIES = [
12
+ {
13
+ type: 'mdk-worker-whatsminer',
14
+ aliases: ['whatsminer', 'whatsminers', 'microbt'],
15
+ models: ['m56s', 'm50s', 'm30s', 'm60']
16
+ },
17
+ {
18
+ type: 'mdk-worker-antminer',
19
+ aliases: ['antminer', 'antminers', 'bitmain'],
20
+ models: ['s19xp', 's19 xp', 's19', 's21']
21
+ },
22
+ {
23
+ type: 'mdk-worker-avalon',
24
+ aliases: ['avalon', 'avalons', 'canaan'],
25
+ models: []
26
+ }
27
+ ]
28
+
29
+ const ONES = {
30
+ zero: 0, a: 1, an: 1, one: 1, two: 2, three: 3, four: 4, five: 5,
31
+ six: 6, seven: 7, eight: 8, nine: 9, ten: 10, eleven: 11, twelve: 12,
32
+ thirteen: 13, fourteen: 14, fifteen: 15, sixteen: 16, seventeen: 17,
33
+ eighteen: 18, nineteen: 19, handful: 5
34
+ }
35
+
36
+ const TENS = {
37
+ twenty: 20, thirty: 30, forty: 40, fifty: 50,
38
+ sixty: 60, seventy: 70, eighty: 80, ninety: 90
39
+ }
40
+
41
+ const STATUS_RANK = { green: 0, amber: 1, red: 2 }
42
+
43
+ export function typeKey (workers) {
44
+ return [...new Set((workers || []).map((w) => w.type).filter(Boolean))].sort().join(',')
45
+ }
46
+
47
+ export function familyForAlias (token) {
48
+ const t = String(token || '').toLowerCase().replace(/\s+/g, '')
49
+ for (const fam of FAMILIES) {
50
+ if (fam.type === token) return fam
51
+ if (fam.aliases.some((a) => a.replace(/\s+/g, '') === t)) return fam
52
+ if (fam.models.some((m) => m.replace(/\s+/g, '') === t)) return fam
53
+ }
54
+ return null
55
+ }
56
+
57
+ function parseNumberPhrase (raw) {
58
+ if (raw == null) return null
59
+ const text = String(raw).toLowerCase().replace(/,/g, '').trim()
60
+ if (!text) return null
61
+ const numeric = Number(text)
62
+ if (Number.isFinite(numeric)) return numeric
63
+ if (ONES[text] != null) return ONES[text]
64
+ if (TENS[text] != null) return TENS[text]
65
+ const hundred = text.match(/^(one|two|three|four|five|six|seven|eight|nine)\s+hundred$/)
66
+ if (hundred) return ONES[hundred[1]] * 100
67
+ const compound = text.match(/^(twenty|thirty|forty|fifty|sixty|seventy|eighty|ninety)[-\s](one|two|three|four|five|six|seven|eight|nine)$/)
68
+ if (compound) return TENS[compound[1]] + ONES[compound[2]]
69
+ return null
70
+ }
71
+
72
+ const NUMBER_TOKEN = String.raw`(?:\d[\d,]*|two\s+hundred|three\s+hundred|four\s+hundred|five\s+hundred|six\s+hundred|seven\s+hundred|eight\s+hundred|nine\s+hundred|one\s+hundred|twenty(?:[-\s](?:one|two|three|four|five|six|seven|eight|nine))?|thirty(?:[-\s](?:one|two|three|four|five|six|seven|eight|nine))?|forty(?:[-\s](?:one|two|three|four|five|six|seven|eight|nine))?|fifty(?:[-\s](?:one|two|three|four|five|six|seven|eight|nine))?|sixty(?:[-\s](?:one|two|three|four|five|six|seven|eight|nine))?|seventy(?:[-\s](?:one|two|three|four|five|six|seven|eight|nine))?|eighty(?:[-\s](?:one|two|three|four|five|six|seven|eight|nine))?|ninety(?:[-\s](?:one|two|three|four|five|six|seven|eight|nine))?|a|an|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|thirteen|fourteen|fifteen|sixteen|seventeen|eighteen|nineteen|handful)`
73
+
74
+ function normalizeModel (raw) {
75
+ if (!raw) return null
76
+ return String(raw).toLowerCase().replace(/\s+/g, '')
77
+ }
78
+
79
+ export function parseSiteDescription (text) {
80
+ const src = String(text || '').replace(/\s+/g, ' ').trim()
81
+ const devices = []
82
+
83
+ const familyAlt = FAMILIES.flatMap((f) => f.aliases).join('|')
84
+ const modelAlt = FAMILIES.flatMap((f) => f.models).map((m) => m.replace(/\s+/g, '\\s*')).join('|')
85
+ const pairRe = new RegExp(
86
+ `(${NUMBER_TOKEN})\\s+(?:of\\s+)?(${familyAlt})s?(?:\\s+(${modelAlt}))?`,
87
+ 'gi'
88
+ )
89
+
90
+ for (const m of src.matchAll(pairRe)) {
91
+ const count = parseNumberPhrase(m[1])
92
+ const fam = familyForAlias(m[2])
93
+ if (count == null || !fam) continue
94
+ devices.push({ type: fam.type, model: normalizeModel(m[3]) || defaultModel(fam), count })
95
+ }
96
+
97
+ if (!devices.length) {
98
+ const modelRe = new RegExp(`(${NUMBER_TOKEN})\\s+(${modelAlt})\\b`, 'gi')
99
+ for (const m of src.matchAll(modelRe)) {
100
+ const count = parseNumberPhrase(m[1])
101
+ const fam = familyForAlias(normalizeModel(m[2]))
102
+ if (count == null || !fam) continue
103
+ devices.push({ type: fam.type, model: normalizeModel(m[2]), count })
104
+ }
105
+ }
106
+
107
+ const mentioned = new Set()
108
+ for (const fam of FAMILIES) {
109
+ const re = new RegExp(`\\b(?:${fam.aliases.join('|')})s?\\b`, 'i')
110
+ if (re.test(src)) mentioned.add(fam.type)
111
+ }
112
+
113
+ const totalFromDevices = devices.reduce((s, d) => s + (d.count || 0), 0)
114
+ let deviceCount = totalFromDevices || null
115
+
116
+ if (!devices.length && mentioned.size) {
117
+ const generic = src.match(new RegExp(`(${NUMBER_TOKEN})\\s+(?:miners?|asics?|devices?|boxes|units)\\b`, 'i'))
118
+ const count = generic ? parseNumberPhrase(generic[1]) : null
119
+ deviceCount = count
120
+ for (const type of mentioned) {
121
+ const fam = FAMILIES.find((f) => f.type === type)
122
+ devices.push({ type, model: defaultModel(fam), count: mentioned.size === 1 ? count : null })
123
+ }
124
+ }
125
+
126
+ if (!devices.length) {
127
+ const generic = src.match(new RegExp(`(${NUMBER_TOKEN})\\s+(?:miners?|asics?|devices?)\\b`, 'i'))
128
+ if (generic) {
129
+ deviceCount = parseNumberPhrase(generic[1])
130
+ devices.push({ type: 'mdk-worker-whatsminer', model: 'm56s', count: deviceCount, assumed: true })
131
+ }
132
+ }
133
+
134
+ const cores =
135
+ matchNumber(src, /(\d+)\s*-?\s*core\b/i) ??
136
+ matchNumber(src, /(\d+)\s+physical\s+cores?\b/i) ??
137
+ matchNumber(src, /(\d+)\s+cores?\b/i)
138
+
139
+ const ramGiB =
140
+ matchNumber(src, /(\d+(?:\.\d+)?)\s*(?:gi?b)\b/i) ??
141
+ matchNumber(src, /(\d+(?:\.\d+)?)\s*gb\b/i)
142
+
143
+ let diskType = null
144
+ if (/\bnvme\b/i.test(src)) diskType = 'nvme'
145
+ else if (/\bsata\b/i.test(src)) diskType = 'sata'
146
+ else if (/\bhdd\b/i.test(src)) diskType = 'hdd'
147
+ else if (/\b(?:sd(?:-card)?|microsd|sd-class)\b/i.test(src)) diskType = 'sd'
148
+
149
+ let tier = null
150
+ if (/\bsite[-\s]?server\b/i.test(src)) tier = 'site-server'
151
+ else if (/\bedge\b/i.test(src) || /\b(?:raspberry\s*)?pi\b/i.test(src)) tier = 'edge'
152
+ else tier = inferTier({ cpuCoresPhysical: cores, ramGiB, diskType })
153
+
154
+ const workerHint =
155
+ matchNumber(src, new RegExp(`(${NUMBER_TOKEN})\\s+worker\\s+processes`, 'i')) ??
156
+ matchNumber(src, new RegExp(`(${NUMBER_TOKEN})\\s+workers\\b`, 'i'))
157
+
158
+ return {
159
+ description: src,
160
+ devices,
161
+ deviceCount: deviceCount ?? totalFromDevices ?? null,
162
+ host: {
163
+ tier,
164
+ cpuCoresPhysical: cores,
165
+ ramGiB,
166
+ diskType
167
+ },
168
+ workerCountHint: workerHint,
169
+ kernels: 1,
170
+ gateways: 1
171
+ }
172
+ }
173
+
174
+ function defaultModel (fam) {
175
+ if (!fam) return null
176
+ if (fam.type === 'mdk-worker-whatsminer') return 'm56s'
177
+ if (fam.type === 'mdk-worker-antminer') return 's19xp'
178
+ return null
179
+ }
180
+
181
+ function matchNumber (src, re) {
182
+ const m = src.match(re)
183
+ return m ? parseNumberPhrase(m[1]) : null
184
+ }
185
+
186
+ export function inferTier (host = {}) {
187
+ if (host.tier) return host.tier
188
+ const cores = host.cpuCoresPhysical
189
+ const ram = host.ramGiB
190
+ const disk = host.diskType
191
+ if (disk === 'sd' || disk === 'hdd') return 'edge'
192
+ if (cores != null && cores <= 8 && ram != null && ram <= 16) return 'edge'
193
+ if ((cores != null && cores >= 16) || (ram != null && ram >= 32) || disk === 'nvme') return 'site-server'
194
+ return null
195
+ }
196
+
197
+ export function fromHarnessReport (raw) {
198
+ if (!raw || typeof raw !== 'object') throw new Error('profile is not an object')
199
+ if (raw.rssSumMiB != null && raw.status && raw.workers) {
200
+ return {
201
+ profileId: raw.profileId,
202
+ tier: raw.tier || raw.hardware?.referenceTier || 'custom',
203
+ hardware: raw.hardware || {},
204
+ deviceCount: raw.deviceCount,
205
+ workerCount: raw.workerCount,
206
+ workers: raw.workers,
207
+ status: raw.status,
208
+ headroomRatio: raw.headroomRatio,
209
+ rssSumMiB: raw.rssSumMiB,
210
+ cpuSumPct: raw.cpuSumPct,
211
+ readP99Ms: raw.readP99Ms,
212
+ actionP99Ms: raw.actionP99Ms,
213
+ source: raw.source || 'fixture'
214
+ }
215
+ }
216
+
217
+ const summaries = Object.values(raw.resourceSummary || {})
218
+ const rssSum = summaries.reduce((s, x) => s + (x.avgRssMiB || 0), 0)
219
+ const cpuSum = summaries.reduce((s, x) => s + (x.avgCpuPct || 0), 0)
220
+ return {
221
+ profileId: raw.profileId,
222
+ tier: raw.hardware?.referenceTier || 'custom',
223
+ hardware: raw.hardware || {},
224
+ deviceCount: raw.deviceCount,
225
+ workerCount: raw.workerCount,
226
+ workers: (raw.workersBreakdown || []).map((w) => ({
227
+ type: w.type,
228
+ model: w.model || null,
229
+ deviceCount: w.deviceCount
230
+ })),
231
+ status: raw.verdict?.overall,
232
+ headroomRatio: raw.headroomRatio,
233
+ rssSumMiB: rssSum || null,
234
+ cpuSumPct: cpuSum || null,
235
+ readP99Ms: raw.latencies?.telemetrySingle?.p99Ms ?? raw.latencies?.gatewayTelemetrySingle?.p99Ms,
236
+ actionP99Ms: raw.latencies?.actionSubmit?.p99Ms ?? raw.latencies?.gatewayActionSubmit?.p99Ms,
237
+ source: 'measured'
238
+ }
239
+ }
240
+
241
+ function fitLinear (points, yKey) {
242
+ const usable = points.filter((p) => p.deviceCount > 0 && Number.isFinite(p[yKey]))
243
+ if (usable.length < 2) {
244
+ const one = usable[0]
245
+ if (!one) return null
246
+ return { base: 0, perDevice: one[yKey] / one.deviceCount }
247
+ }
248
+ const sorted = [...usable].sort((a, b) => a.deviceCount - b.deviceCount)
249
+ const lo = sorted[0]
250
+ const hi = sorted[sorted.length - 1]
251
+ const span = hi.deviceCount - lo.deviceCount
252
+ if (span === 0) return { base: 0, perDevice: lo[yKey] / lo.deviceCount }
253
+ const perDevice = (hi[yKey] - lo[yKey]) / span
254
+ const base = lo[yKey] - lo.deviceCount * perDevice
255
+ return { base, perDevice }
256
+ }
257
+
258
+ export function compileEnvelope (rawProfiles, { compiledAt = new Date().toISOString(), provenance } = {}) {
259
+ const profiles = rawProfiles.map(fromHarnessReport).sort((a, b) => a.profileId.localeCompare(b.profileId))
260
+ const measured = profiles.some((p) => p.source === 'measured')
261
+ const supportedUpTo = {}
262
+ const formulas = {}
263
+
264
+ const groups = new Map()
265
+ for (const p of profiles) {
266
+ const key = `${p.tier}|${typeKey(p.workers)}`
267
+ if (!groups.has(key)) groups.set(key, [])
268
+ groups.get(key).push(p)
269
+ }
270
+
271
+ for (const [key, list] of groups) {
272
+ const green = list.filter((p) => p.status === 'green').sort((a, b) => b.deviceCount - a.deviceCount)
273
+ const densest = green[0]
274
+ const [tier, types] = key.split('|')
275
+ if (!supportedUpTo[tier]) supportedUpTo[tier] = {}
276
+ supportedUpTo[tier][types || '*'] = densest
277
+ ? { devices: densest.deviceCount, workers: densest.workerCount, profileId: densest.profileId }
278
+ : null
279
+ formulas[key] = {
280
+ rssMiB: fitLinear(list.filter((p) => p.status !== 'red'), 'rssSumMiB'),
281
+ cpuPct: fitLinear(list.filter((p) => p.status !== 'red'), 'cpuSumPct')
282
+ }
283
+ }
284
+
285
+ return {
286
+ compiledAt,
287
+ provenance: provenance || {
288
+ kind: measured ? 'measured' : 'fixture',
289
+ note: measured
290
+ ? 'Compiled from harness reports.'
291
+ : 'Fixture envelope for skill eval. Replace eval/profiles with committed harness reports (PE-1) and re-run compile-envelope.mjs.'
292
+ },
293
+ thresholds: THRESHOLDS,
294
+ profiles,
295
+ supportedUpTo,
296
+ formulas
297
+ }
298
+ }
299
+
300
+ function scaleFrom (profile, totalD) {
301
+ if (!profile || !profile.deviceCount) return { rssSumMiB: null, cpuSumPct: null, readP99Ms: null, actionP99Ms: null }
302
+ const t = totalD / profile.deviceCount
303
+ return {
304
+ rssSumMiB: profile.rssSumMiB == null ? null : profile.rssSumMiB * t,
305
+ cpuSumPct: profile.cpuSumPct == null ? null : profile.cpuSumPct * t,
306
+ readP99Ms: profile.readP99Ms ?? null,
307
+ actionP99Ms: profile.actionP99Ms ?? null,
308
+ interpolatedBetween: [profile.profileId]
309
+ }
310
+ }
311
+
312
+ function interpolateResources (totalD, smaller, larger) {
313
+ if (!larger) return { rssSumMiB: null, cpuSumPct: null, readP99Ms: null, actionP99Ms: null, interpolatedBetween: [] }
314
+ if (!smaller || smaller.profileId === larger.profileId) return scaleFrom(larger, totalD)
315
+ const span = larger.deviceCount - smaller.deviceCount
316
+ if (span <= 0) return scaleFrom(larger, totalD)
317
+ const t = (totalD - smaller.deviceCount) / span
318
+ const mix = (a, b) => (a == null || b == null ? b ?? a : a + t * (b - a))
319
+ return {
320
+ rssSumMiB: mix(smaller.rssSumMiB, larger.rssSumMiB),
321
+ cpuSumPct: mix(smaller.cpuSumPct, larger.cpuSumPct),
322
+ readP99Ms: mix(smaller.readP99Ms, larger.readP99Ms),
323
+ actionP99Ms: mix(smaller.actionP99Ms, larger.actionP99Ms),
324
+ interpolatedBetween: [smaller.profileId, larger.profileId]
325
+ }
326
+ }
327
+
328
+ function unmeasured (facts, reason) {
329
+ return {
330
+ status: 'unmeasured',
331
+ reason,
332
+ tier: facts.host?.tier || null,
333
+ deviceCount: facts.deviceCount,
334
+ workerCount: null,
335
+ devicesPerWorker: null,
336
+ kernels: facts.kernels ?? 1,
337
+ gateways: facts.gateways ?? 1,
338
+ matchingProfileId: null,
339
+ rssSumMiB: null,
340
+ cpuSumPct: null,
341
+ readP99Ms: null,
342
+ actionP99Ms: null,
343
+ envelope: { supportedUpTo: null, nearestProfileId: null },
344
+ caveats: [
345
+ reason === 'family-not-in-envelope'
346
+ ? 'No measured profile covers this device family. Run the capacity harness before quoting a size.'
347
+ : 'Not enough measured data to size this site. Run the capacity harness on a matching host.'
348
+ ]
349
+ }
350
+ }
351
+
352
+ function beyondEnvelope ({ facts, nearest, supportedUpTo, resources, workerCount, devicesPerWorker }) {
353
+ return {
354
+ status: 'beyond-envelope',
355
+ reason: 'requested-devices-exceed-green-and-amber',
356
+ tier: facts.host?.tier || nearest?.tier || null,
357
+ deviceCount: facts.deviceCount,
358
+ workerCount,
359
+ devicesPerWorker,
360
+ kernels: facts.kernels ?? 1,
361
+ gateways: facts.gateways ?? 1,
362
+ matchingProfileId: nearest?.profileId || null,
363
+ rssSumMiB: resources.rssSumMiB,
364
+ cpuSumPct: resources.cpuSumPct,
365
+ readP99Ms: resources.readP99Ms,
366
+ actionP99Ms: resources.actionP99Ms,
367
+ envelope: {
368
+ supportedUpTo: supportedUpTo
369
+ ? { devices: supportedUpTo.devices, workers: supportedUpTo.workers, profileId: supportedUpTo.profileId }
370
+ : null,
371
+ nearestProfileId: nearest?.profileId || null
372
+ },
373
+ caveats: [
374
+ `Requested ${facts.deviceCount} devices is past the densest green profile` +
375
+ (supportedUpTo ? ` (${supportedUpTo.profileId}, ${supportedUpTo.devices} devices)` : '') +
376
+ '. Do not claim this layout is supported. Run the harness at this size.'
377
+ ]
378
+ }
379
+ }
380
+
381
+ export function sizeSite (facts, envelope) {
382
+ const devices = facts.devices || []
383
+ const totalD = facts.deviceCount ?? devices.reduce((s, d) => s + (Number(d.count) || 0), 0)
384
+ const types = [...new Set(devices.map((d) => d.type).filter(Boolean))].sort()
385
+ const tier = facts.host?.tier || inferTier(facts.host)
386
+ facts = { ...facts, deviceCount: totalD, host: { ...facts.host, tier } }
387
+
388
+ if (!types.length) return unmeasured(facts, 'family-not-in-envelope')
389
+ const knownTypes = new Set(envelope.profiles.flatMap((p) => p.workers.map((w) => w.type)))
390
+ if (types.some((t) => !knownTypes.has(t))) return unmeasured(facts, 'family-not-in-envelope')
391
+ if (!totalD) return unmeasured(facts, 'device-count-unknown')
392
+
393
+ const mixKey = types.join(',')
394
+ const sameMix = envelope.profiles.filter((p) => typeKey(p.workers) === mixKey)
395
+ if (!sameMix.length) return unmeasured(facts, 'family-not-in-envelope')
396
+
397
+ let candidates = sameMix.filter((p) => !tier || p.tier === tier || p.tier === 'custom')
398
+ if (!candidates.length) candidates = sameMix
399
+
400
+ const covering = [...candidates]
401
+ .filter((p) => p.deviceCount >= totalD)
402
+ .sort((a, b) => (STATUS_RANK[a.status] - STATUS_RANK[b.status]) || (a.deviceCount - b.deviceCount))
403
+
404
+ const greenCovering = covering.filter((p) => p.status === 'green')
405
+ const amberCovering = covering.filter((p) => p.status === 'amber')
406
+ const maxGreen = [...candidates].filter((p) => p.status === 'green').sort((a, b) => b.deviceCount - a.deviceCount)[0]
407
+ const maxNonRed = [...candidates].filter((p) => p.status !== 'red').sort((a, b) => b.deviceCount - a.deviceCount)[0]
408
+ const maxAny = [...candidates].sort((a, b) => b.deviceCount - a.deviceCount)[0]
409
+
410
+ const supported = maxGreen
411
+ ? { devices: maxGreen.deviceCount, workers: maxGreen.workerCount, profileId: maxGreen.profileId }
412
+ : null
413
+
414
+ if (greenCovering.length) {
415
+ const chosen = greenCovering[0]
416
+ const smaller = [...candidates]
417
+ .filter((p) => p.status === 'green' && p.deviceCount <= totalD)
418
+ .sort((a, b) => b.deviceCount - a.deviceCount)[0] || chosen
419
+ const devicesPerWorker = chosen.deviceCount / chosen.workerCount
420
+ const workerCount = Math.max(1, Math.ceil(totalD / devicesPerWorker))
421
+ const resources = interpolateResources(totalD, smaller, chosen)
422
+ return {
423
+ status: 'green',
424
+ reason: totalD === chosen.deviceCount ? 'exact-profile' : 'within-green-envelope',
425
+ tier: chosen.tier === 'custom' ? (tier || 'custom') : chosen.tier,
426
+ deviceCount: totalD,
427
+ workerCount,
428
+ devicesPerWorker,
429
+ kernels: facts.kernels ?? 1,
430
+ gateways: facts.gateways ?? 1,
431
+ matchingProfileId: chosen.profileId,
432
+ workers: scaleWorkers(devices, workerCount, totalD),
433
+ rssSumMiB: resources.rssSumMiB,
434
+ cpuSumPct: resources.cpuSumPct,
435
+ readP99Ms: resources.readP99Ms,
436
+ actionP99Ms: resources.actionP99Ms,
437
+ interpolatedBetween: resources.interpolatedBetween,
438
+ envelope: {
439
+ supportedUpTo: supported,
440
+ nearestProfileId: chosen.profileId
441
+ },
442
+ caveats: chosen.source === 'fixture'
443
+ ? ['Envelope is fixture data until PE-1 commits a measured harness report.']
444
+ : []
445
+ }
446
+ }
447
+
448
+ if (amberCovering.length) {
449
+ const chosen = amberCovering[0]
450
+ const devicesPerWorker = chosen.deviceCount / chosen.workerCount
451
+ const workerCount = Math.max(1, Math.ceil(totalD / devicesPerWorker))
452
+ const resources = scaleFrom(chosen, totalD)
453
+ return {
454
+ status: 'amber',
455
+ reason: 'within-amber-envelope',
456
+ tier: chosen.tier === 'custom' ? (tier || 'custom') : chosen.tier,
457
+ deviceCount: totalD,
458
+ workerCount,
459
+ devicesPerWorker,
460
+ kernels: facts.kernels ?? 1,
461
+ gateways: facts.gateways ?? 1,
462
+ matchingProfileId: chosen.profileId,
463
+ workers: scaleWorkers(devices, workerCount, totalD),
464
+ rssSumMiB: resources.rssSumMiB,
465
+ cpuSumPct: resources.cpuSumPct,
466
+ readP99Ms: resources.readP99Ms,
467
+ actionP99Ms: resources.actionP99Ms,
468
+ interpolatedBetween: resources.interpolatedBetween,
469
+ envelope: {
470
+ supportedUpTo: supported,
471
+ nearestProfileId: chosen.profileId
472
+ },
473
+ caveats: [
474
+ `${chosen.profileId} is amber at ${chosen.deviceCount} devices. Treat as little spare capacity, not a supported-up-to bound.`
475
+ ]
476
+ }
477
+ }
478
+
479
+ const nearest = maxNonRed || maxAny
480
+ const devicesPerWorker = nearest && nearest.workerCount
481
+ ? nearest.deviceCount / nearest.workerCount
482
+ : null
483
+ const workerCount = devicesPerWorker ? Math.max(1, Math.ceil(totalD / devicesPerWorker)) : null
484
+ return beyondEnvelope({
485
+ facts,
486
+ nearest: maxAny,
487
+ supportedUpTo: supported,
488
+ resources: nearest ? scaleFrom(nearest, totalD) : {},
489
+ workerCount,
490
+ devicesPerWorker
491
+ })
492
+ }
493
+
494
+ function scaleWorkers (devices, workerCount, totalD) {
495
+ if (!devices.length || !workerCount) return []
496
+ if (devices.every((d) => d.count == null) && totalD) {
497
+ const share = Math.ceil(totalD / devices.length)
498
+ return devices.map((d, i) => ({
499
+ type: d.type,
500
+ model: d.model,
501
+ deviceCount: i === devices.length - 1 ? totalD - share * (devices.length - 1) : share,
502
+ workerCount: 1
503
+ }))
504
+ }
505
+ return devices.map((d) => ({
506
+ type: d.type,
507
+ model: d.model,
508
+ deviceCount: d.count,
509
+ workerCount: Math.max(1, Math.ceil((d.count || 0) / Math.max(1, Math.ceil(totalD / workerCount))))
510
+ }))
511
+ }
512
+
513
+ export function matchRecommendation (rec, expected, { rssTolerance = 0.2 } = {}) {
514
+ const errors = []
515
+ if (expected.status && rec.status !== expected.status) {
516
+ errors.push(`status: got ${rec.status}, expected ${expected.status}`)
517
+ }
518
+ if (expected.tier && rec.tier !== expected.tier) {
519
+ errors.push(`tier: got ${rec.tier}, expected ${expected.tier}`)
520
+ }
521
+ const kernels = expected.kernels ?? 1
522
+ const gateways = expected.gateways ?? 1
523
+ if (rec.kernels !== kernels) errors.push(`kernels: got ${rec.kernels}, expected ${kernels}`)
524
+ if (rec.gateways !== gateways) errors.push(`gateways: got ${rec.gateways}, expected ${gateways}`)
525
+
526
+ if (expected.workerCount != null) {
527
+ const [lo, hi] = Array.isArray(expected.workerCount)
528
+ ? expected.workerCount
529
+ : [expected.workerCount, expected.workerCount]
530
+ if (rec.workerCount < lo || rec.workerCount > hi) {
531
+ errors.push(`workerCount: got ${rec.workerCount}, expected [${lo}, ${hi}]`)
532
+ }
533
+ }
534
+
535
+ if (expected.matchingProfileId && rec.matchingProfileId !== expected.matchingProfileId) {
536
+ errors.push(`matchingProfileId: got ${rec.matchingProfileId}, expected ${expected.matchingProfileId}`)
537
+ }
538
+
539
+ if (expected.supportedUpToDevices != null) {
540
+ const got = rec.envelope?.supportedUpTo?.devices
541
+ if (got !== expected.supportedUpToDevices) {
542
+ errors.push(`supportedUpTo.devices: got ${got}, expected ${expected.supportedUpToDevices}`)
543
+ }
544
+ }
545
+
546
+ if (expected.rssSumMiB != null && rec.rssSumMiB != null) {
547
+ const [lo, hi] = Array.isArray(expected.rssSumMiB)
548
+ ? expected.rssSumMiB
549
+ : [expected.rssSumMiB * (1 - rssTolerance), expected.rssSumMiB * (1 + rssTolerance)]
550
+ if (rec.rssSumMiB < lo || rec.rssSumMiB > hi) {
551
+ errors.push(`rssSumMiB: got ${rec.rssSumMiB}, expected [${lo}, ${hi}]`)
552
+ }
553
+ }
554
+
555
+ return { ok: errors.length === 0, errors }
556
+ }
557
+
558
+ export function renderRecommendation (rec) {
559
+ const rss = rec.rssSumMiB == null ? '_' : `${Math.round(rec.rssSumMiB)} MiB`
560
+ const cpu = rec.cpuSumPct == null ? '_' : `${rec.cpuSumPct.toFixed(1)}% of 1 core (sum)`
561
+ const lines = [
562
+ `# Site sizing recommendation`,
563
+ ``,
564
+ `| Field | Value |`,
565
+ `| --- | --- |`,
566
+ `| Status | **${rec.status}** |`,
567
+ `| Reference tier | ${rec.tier ?? '_'} |`,
568
+ `| Devices | ${rec.deviceCount ?? '_'} |`,
569
+ `| Workers | ${rec.workerCount ?? '_'} |`,
570
+ `| Devices per Worker | ${rec.devicesPerWorker == null ? '_' : rec.devicesPerWorker} |`,
571
+ `| Kernels | ${rec.kernels} |`,
572
+ `| Gateways | ${rec.gateways} |`,
573
+ `| Matching profile | ${rec.matchingProfileId ?? '_'} |`,
574
+ `| Estimated RSS (site) | ${rss} |`,
575
+ `| Estimated CPU (site) | ${cpu} |`,
576
+ `| Read p99 | ${rec.readP99Ms == null ? '_' : `${Math.round(rec.readP99Ms)} ms`} |`,
577
+ `| Action p99 | ${rec.actionP99Ms == null ? '_' : `${Math.round(rec.actionP99Ms)} ms`} |`
578
+ ]
579
+ if (rec.envelope?.supportedUpTo) {
580
+ const s = rec.envelope.supportedUpTo
581
+ lines.push(``, `Supported up to (densest green): **${s.devices} devices / ${s.workers} Workers** (\`${s.profileId}\`).`)
582
+ }
583
+ if (rec.caveats?.length) {
584
+ lines.push(``, `## Caveats`, ...rec.caveats.map((c) => `- ${c}`))
585
+ }
586
+ lines.push(
587
+ ``,
588
+ `Numbers come from the measured envelope, not from a guess. Hand off to \`mdk-deployment\` to write \`mdk.yaml\`.`
589
+ )
590
+ return lines.join('\n') + '\n'
591
+ }
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env node
2
+ // Parse a plain site description into sizing facts JSON (stdout).
3
+ //
4
+ // node parse-site.mjs "ten Whatsminer M56S on an edge box, 4 cores, 8 GiB, SATA"
5
+ // node parse-site.mjs < site.txt (preferred for operator-written text)
6
+
7
+ import { parseSiteDescription } from './lib.mjs'
8
+
9
+ const fromArgs = process.argv.slice(2).join(' ').trim()
10
+ const text = fromArgs || await readStdin()
11
+ if (!text) {
12
+ console.error('usage: node parse-site.mjs <site description>')
13
+ process.exit(2)
14
+ }
15
+ process.stdout.write(JSON.stringify(parseSiteDescription(text), null, 2) + '\n')
16
+
17
+ function readStdin () {
18
+ return new Promise((resolve) => {
19
+ if (process.stdin.isTTY) return resolve('')
20
+ let buf = ''
21
+ process.stdin.setEncoding('utf8')
22
+ process.stdin.on('data', (c) => { buf += c })
23
+ process.stdin.on('end', () => resolve(buf.trim()))
24
+ })
25
+ }
@@ -0,0 +1,49 @@
1
+ #!/usr/bin/env node
2
+ // Size a site from facts JSON or a plain description. Prints JSON (default)
3
+ // or Markdown (--md). Never invents numbers: lookup is envelope-only.
4
+ //
5
+ // node size-site.mjs facts.json
6
+ // node size-site.mjs --description-file site.txt
7
+ // node size-site.mjs --description "eighty Whatsminer M56S on a site-server"
8
+ //
9
+ // Prefer --description-file for operator-written text: it never passes through
10
+ // a shell, so quotes, `$( )` or backticks in the description are inert.
11
+ // node size-site.mjs --envelope ../references/envelope.json facts.json --md
12
+
13
+ import fs from 'node:fs'
14
+ import path from 'node:path'
15
+ import { fileURLToPath } from 'node:url'
16
+ import { parseSiteDescription, sizeSite, renderRecommendation } from './lib.mjs'
17
+
18
+ const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url))
19
+ const DEFAULT_ENVELOPE = path.resolve(SCRIPT_DIR, '..', 'references', 'envelope.json')
20
+
21
+ const args = process.argv.slice(2)
22
+ let envelopePath = DEFAULT_ENVELOPE
23
+ let description = null
24
+ let factsPath = null
25
+ let asMd = false
26
+
27
+ for (let i = 0; i < args.length; i++) {
28
+ const a = args[i]
29
+ if (a === '--md') asMd = true
30
+ else if (a === '--envelope') envelopePath = args[++i]
31
+ else if (a === '--description') description = args[++i]
32
+ else if (a === '--description-file') description = fs.readFileSync(args[++i], 'utf8').trim()
33
+ else if (a.startsWith('-')) {
34
+ console.error(`unknown flag ${a}`)
35
+ process.exit(2)
36
+ } else factsPath = a
37
+ }
38
+
39
+ if (!description && !factsPath) {
40
+ console.error('usage: node size-site.mjs [--envelope <path>] [--md] <facts.json | --description-file <file> | --description "...">')
41
+ process.exit(2)
42
+ }
43
+
44
+ const envelope = JSON.parse(fs.readFileSync(envelopePath, 'utf8'))
45
+ const facts = description
46
+ ? parseSiteDescription(description)
47
+ : JSON.parse(fs.readFileSync(factsPath, 'utf8'))
48
+ const rec = sizeSite(facts, envelope)
49
+ process.stdout.write(asMd ? renderRecommendation(rec) : JSON.stringify({ facts, recommendation: rec }, null, 2) + '\n')