@sligqoer/dsh-usage-stats 0.1.2 → 0.2.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/CHANGELOG.md +64 -0
- package/README.md +48 -9
- package/README.zh-CN.md +48 -9
- package/lib/client.js +717 -31
- package/lib/fingerprints/KBF-LICENSE.txt +201 -0
- package/lib/fingerprints/NOTICE.txt +6 -0
- package/lib/fingerprints/kbf-202609.json +1 -0
- package/lib/index.js +320 -26
- package/lib/model-capabilities.js +41 -0
- package/lib/model-fingerprints.js +146 -0
- package/lib/model-match.js +407 -0
- package/lib/model-test-history.js +93 -0
- package/lib/provider-quotas.js +26 -5
- package/lib/quota-controls.js +60 -19
- package/package.json +10 -3
- package/screenshots/controls.png +0 -0
- package/screenshots/match.png +0 -0
- package/screenshots/quotas.png +0 -0
- package/screenshots/token-speed.png +0 -0
- package/screenshots/usage.png +0 -0
package/lib/index.js
CHANGED
|
@@ -29,6 +29,8 @@ import { dirname, join } from 'node:path'
|
|
|
29
29
|
import { homedir } from 'node:os'
|
|
30
30
|
import { createProviderQuotaReader } from './provider-quotas.js'
|
|
31
31
|
import { createQuotaControls, workBuddyQuotaSource, QuotaControlError } from './quota-controls.js'
|
|
32
|
+
import { captureModelEvidence, normalizeEvidence, analyzeModelEvidence, createModelMatchRunner, ModelMatchError, modelSelection } from './model-match.js'
|
|
33
|
+
import { createModelTestHistory } from './model-test-history.js'
|
|
32
34
|
|
|
33
35
|
/** Stable cordis plugin name (row id `usage-stats`). */
|
|
34
36
|
export const name = 'usage-stats'
|
|
@@ -46,6 +48,8 @@ const MAX_WAIT_MS = 1500
|
|
|
46
48
|
const SCAN_CONCURRENCY = 6
|
|
47
49
|
/** Persisted corpus file name under <DSH_HOME>/storages. */
|
|
48
50
|
const STORE_FILE = 'usage-stats-corpus.json'
|
|
51
|
+
/** Rescan live logs when persisted speed timing fields change. */
|
|
52
|
+
const SPEED_VERSION = 2
|
|
49
53
|
|
|
50
54
|
/** Fallback for requests without a desktop timezone. */
|
|
51
55
|
const SYSTEM_TZ = new Date().getTimezoneOffset()
|
|
@@ -220,6 +224,38 @@ function putBucket(map, key, usage) {
|
|
|
220
224
|
addUsage(b, usage)
|
|
221
225
|
}
|
|
222
226
|
|
|
227
|
+
/** Mirror dsh-llm's assistantStreamFirstTokenTime for packed and raw records.
|
|
228
|
+
* Empty deltas do not start decoding; a tool name does. Never retain text. */
|
|
229
|
+
function firstOutputTime(stream) {
|
|
230
|
+
if (!Array.isArray(stream)) return null
|
|
231
|
+
for (const item of stream) {
|
|
232
|
+
if (!item) continue
|
|
233
|
+
if (item.type === 'chunk') {
|
|
234
|
+
const chunk = item.chunk
|
|
235
|
+
const hasToken = chunk && (['text-delta', 'reasoning-delta'].includes(chunk.type)
|
|
236
|
+
? typeof chunk.text === 'string' && chunk.text !== ''
|
|
237
|
+
: chunk.type === 'tool-call-delta' && (typeof chunk.argumentsDelta === 'string' && chunk.argumentsDelta !== '' || chunk.name !== undefined))
|
|
238
|
+
if (hasToken) return Number.isFinite(item.time) && item.time > 0 ? item.time : null
|
|
239
|
+
continue
|
|
240
|
+
}
|
|
241
|
+
if (!['text-chunks', 'reasoning-chunks', 'tool-call-chunks'].includes(item.type)) continue
|
|
242
|
+
const parts = item.type === 'tool-call-chunks' ? item.args : item.texts
|
|
243
|
+
if (!Array.isArray(parts) || !parts.length) continue
|
|
244
|
+
if (!Number.isFinite(item.time0) || item.time0 <= 0 || !Array.isArray(item.dt) || item.dt.length !== parts.length - 1) return null
|
|
245
|
+
let time = item.time0
|
|
246
|
+
if (item.type === 'tool-call-chunks' && item.name !== undefined) return time
|
|
247
|
+
for (let i = 0; i < parts.length; i++) {
|
|
248
|
+
if (i > 0) {
|
|
249
|
+
const delta = item.dt[i - 1]
|
|
250
|
+
if (!Number.isFinite(delta) || delta < 0) return null
|
|
251
|
+
time += delta
|
|
252
|
+
}
|
|
253
|
+
if (typeof parts[i] === 'string' && parts[i] !== '') return Number.isFinite(time) ? time : null
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return null
|
|
257
|
+
}
|
|
258
|
+
|
|
223
259
|
/**
|
|
224
260
|
* Fold one session's events into a per-session contribution keyed by local
|
|
225
261
|
* date (day/week), date|modelKey, and modelKey. Uses plain object maps so the
|
|
@@ -227,8 +263,14 @@ function putBucket(map, key, usage) {
|
|
|
227
263
|
* @param events - the session's raw event log.
|
|
228
264
|
* @param tz - UTC offset (minutes) used for day/week keys.
|
|
229
265
|
*/
|
|
230
|
-
export function scanSessionEvents(events, tz) {
|
|
266
|
+
export function scanSessionEvents(events, tz, inheritedEventCount = 0) {
|
|
231
267
|
const s = {
|
|
268
|
+
detailsVersion: 1,
|
|
269
|
+
ownUsageVersion: 1,
|
|
270
|
+
speedVersion: SPEED_VERSION,
|
|
271
|
+
taskVersion: 1,
|
|
272
|
+
longestTaskMs: null,
|
|
273
|
+
evidence: captureModelEvidence(events, inheritedEventCount),
|
|
232
274
|
messages: 0,
|
|
233
275
|
withUsage: false,
|
|
234
276
|
totals: emptyBucket(),
|
|
@@ -240,9 +282,47 @@ export function scanSessionEvents(events, tz) {
|
|
|
240
282
|
byHourModel: Object.create(null),
|
|
241
283
|
byModel: Object.create(null),
|
|
242
284
|
usageEvents: [],
|
|
285
|
+
ttftEvents: [],
|
|
243
286
|
}
|
|
244
|
-
|
|
245
|
-
|
|
287
|
+
let openStep = null
|
|
288
|
+
const turns = new Map()
|
|
289
|
+
const inherited = Number.isInteger(inheritedEventCount) && inheritedEventCount > 0 ? inheritedEventCount : 0
|
|
290
|
+
for (const [index, ev] of (events || []).entries()) {
|
|
291
|
+
// A fork borrows history; only its child-owned events incurred new spend.
|
|
292
|
+
if (!ev || index < inherited) continue
|
|
293
|
+
if (ev.type === 'turn/start' && ev.data?.turn !== undefined && Number.isFinite(ev.time) && ev.time > 0) {
|
|
294
|
+
// One processing round; idle gaps between user messages never enter it.
|
|
295
|
+
if (!turns.has(ev.data.turn)) turns.set(ev.data.turn, ev.time)
|
|
296
|
+
}
|
|
297
|
+
if (ev.type === 'turn/end') {
|
|
298
|
+
const start = turns.get(ev.data?.turn)
|
|
299
|
+
if (Number.isFinite(start) && Number.isFinite(ev.time) && ev.time >= start) s.longestTaskMs = Math.max(s.longestTaskMs ?? 0, ev.time - start)
|
|
300
|
+
turns.delete(ev.data?.turn)
|
|
301
|
+
}
|
|
302
|
+
if (ev.type === 'step/start') {
|
|
303
|
+
openStep = { turn: ev.data?.turn, step: ev.data?.step, startTime: ev.time, firstTokenTime: null }
|
|
304
|
+
continue
|
|
305
|
+
}
|
|
306
|
+
if (ev.type === 'step/end' || ev.type === 'turn/end') { openStep = null; continue }
|
|
307
|
+
const sameStep = openStep && openStep.turn === ev.data?.turn && openStep.step === ev.data?.step
|
|
308
|
+
if (ev.type === 'assistant/attempt') {
|
|
309
|
+
if (sameStep && openStep.firstTokenTime === null) openStep.firstTokenTime = firstOutputTime(ev.data?.stream)
|
|
310
|
+
continue
|
|
311
|
+
}
|
|
312
|
+
if (ev.type !== 'assistant/message') continue
|
|
313
|
+
// DSH's sessionStats projection decodes from the first non-empty token to
|
|
314
|
+
// the assembled message, retaining an earlier attempt's first token on retry.
|
|
315
|
+
const firstToken = sameStep ? (openStep.firstTokenTime ?? firstOutputTime(ev.data?.stream)) : null
|
|
316
|
+
const hasTiming = sameStep && Number.isFinite(openStep.startTime) && Number.isFinite(ev.time) && ev.time > 0
|
|
317
|
+
&& firstToken !== null && firstToken >= openStep.startTime && firstToken <= ev.time
|
|
318
|
+
const outputMs = hasTiming && Number.isFinite(ev.data?.usage?.outputTokens) && ev.data.usage.outputTokens >= 0 ? ev.time - firstToken : 0
|
|
319
|
+
const source = ev.data?.message?.source
|
|
320
|
+
const provider = source && source.provider ? String(source.provider) : 'unknown'
|
|
321
|
+
const model = source && source.model ? String(source.model) : 'unknown'
|
|
322
|
+
// DSH counts TTFT for completed, timed messages even without token usage.
|
|
323
|
+
// Keep these samples separate from TPS and never retain stream content.
|
|
324
|
+
if (hasTiming) s.ttftEvents.push([ev.time, provider, model, firstToken - openStep.startTime])
|
|
325
|
+
if (sameStep) openStep = null
|
|
246
326
|
const usage = ev.data && ev.data.usage
|
|
247
327
|
if (!usage) continue
|
|
248
328
|
const time = ev.time
|
|
@@ -250,16 +330,13 @@ export function scanSessionEvents(events, tz) {
|
|
|
250
330
|
s.withUsage = true
|
|
251
331
|
s.messages += 1
|
|
252
332
|
|
|
253
|
-
const source = ev.data.message && ev.data.message.source
|
|
254
|
-
const provider = source && source.provider ? String(source.provider) : 'unknown'
|
|
255
|
-
const model = source && source.model ? String(source.model) : 'unknown'
|
|
256
333
|
const modelKey = provider + '/' + model
|
|
257
334
|
|
|
258
335
|
// Persist only the fields needed to re-bucket retained sessions exactly.
|
|
259
336
|
s.usageEvents.push([time, provider, model,
|
|
260
337
|
Number(usage.inputTokens) || 0, Number(usage.outputTokens) || 0,
|
|
261
338
|
Number(usage.cacheReadTokens) || 0, Number(usage.cacheWriteTokens) || 0,
|
|
262
|
-
Number(usage.reasoningTokens) || 0])
|
|
339
|
+
Number(usage.reasoningTokens) || 0, outputMs])
|
|
263
340
|
|
|
264
341
|
const dayKey = localDayKey(time, tz)
|
|
265
342
|
const hour = localHourKey(time, tz)
|
|
@@ -334,6 +411,15 @@ function normalizeSession(s) {
|
|
|
334
411
|
}
|
|
335
412
|
void base
|
|
336
413
|
const norm = {
|
|
414
|
+
detailsVersion: s.detailsVersion === 1 ? 1 : 0,
|
|
415
|
+
ownUsageVersion: s.ownUsageVersion === 1 ? 1 : 0,
|
|
416
|
+
speedVersion: s.speedVersion === SPEED_VERSION ? SPEED_VERSION : s.speedVersion === 1 ? 1 : 0,
|
|
417
|
+
taskVersion: s.taskVersion === 1 ? 1 : 0,
|
|
418
|
+
longestTaskMs: s.taskVersion === 1 && Number.isFinite(s.longestTaskMs) && s.longestTaskMs >= 0 ? s.longestTaskMs : null,
|
|
419
|
+
title: safeSessionText(s.title, 300),
|
|
420
|
+
cwd: safeSessionText(s.cwd, 1024),
|
|
421
|
+
createdAt: Number.isFinite(s.createdAt) ? s.createdAt : 0,
|
|
422
|
+
evidence: normalizeEvidence(s.evidence),
|
|
337
423
|
fp: (s.fp && Number.isFinite(s.fp.size) && Number.isFinite(s.fp.mtimeMs)) ? { size: s.fp.size, mtimeMs: s.fp.mtimeMs } : null,
|
|
338
424
|
retained: !!s.retained,
|
|
339
425
|
syncedAt: Number.isFinite(s.syncedAt) ? s.syncedAt : 0,
|
|
@@ -347,7 +433,10 @@ function normalizeSession(s) {
|
|
|
347
433
|
byWeekModel: s.byWeekModel && typeof s.byWeekModel === 'object' ? s.byWeekModel : {},
|
|
348
434
|
byHourModel: s.byHourModel && typeof s.byHourModel === 'object' ? s.byHourModel : {},
|
|
349
435
|
byModel: s.byModel && typeof s.byModel === 'object' ? s.byModel : {},
|
|
350
|
-
usageEvents: Array.isArray(s.usageEvents) ? s.usageEvents.filter((e) => Array.isArray(e) && e.length === 8 && Number.isFinite(e[0])) : null,
|
|
436
|
+
usageEvents: Array.isArray(s.usageEvents) ? s.usageEvents.filter((e) => Array.isArray(e) && (e.length === 8 || e.length === 9) && Number.isFinite(e[0])) : null,
|
|
437
|
+
ttftEvents: Array.isArray(s.ttftEvents) ? s.ttftEvents.filter((e) => Array.isArray(e) && e.length === 4
|
|
438
|
+
&& Number.isFinite(e[0]) && e[0] > 0 && typeof e[1] === 'string' && typeof e[2] === 'string'
|
|
439
|
+
&& Number.isFinite(e[3]) && e[3] >= 0) : [],
|
|
351
440
|
}
|
|
352
441
|
// Recompute per-session totals from day buckets if they are missing (older snapshots).
|
|
353
442
|
if (!(s.totals && typeof s.totals === 'object' && 'billed' in s.totals)) {
|
|
@@ -488,7 +577,11 @@ function rekeyLegacySession(s, fromTz, toTz) {
|
|
|
488
577
|
data: { message: { source: { provider: e[1], model: e[2] } },
|
|
489
578
|
usage: { inputTokens: e[3], outputTokens: e[4], cacheReadTokens: e[5], cacheWriteTokens: e[6], reasoningTokens: e[7] } },
|
|
490
579
|
}))
|
|
491
|
-
return { ...scanSessionEvents(events, toTz),
|
|
580
|
+
return { ...s, ...scanSessionEvents(events, toTz), detailsVersion: s.detailsVersion, evidence: s.evidence,
|
|
581
|
+
ownUsageVersion: s.ownUsageVersion,
|
|
582
|
+
speedVersion: s.speedVersion, usageEvents: s.usageEvents, ttftEvents: s.ttftEvents,
|
|
583
|
+
taskVersion: s.taskVersion, longestTaskMs: s.longestTaskMs,
|
|
584
|
+
fp: s.fp, retained: s.retained, syncedAt: s.syncedAt }
|
|
492
585
|
}
|
|
493
586
|
|
|
494
587
|
const next = {
|
|
@@ -577,7 +670,7 @@ export async function syncCorpus(ctx, reqTz, state) {
|
|
|
577
670
|
toRead.push(id)
|
|
578
671
|
continue
|
|
579
672
|
}
|
|
580
|
-
if (prev === undefined) {
|
|
673
|
+
if (prev === undefined || prev.detailsVersion !== 1 || prev.ownUsageVersion !== 1 || prev.speedVersion !== SPEED_VERSION || prev.taskVersion !== 1) {
|
|
581
674
|
toRead.push(id)
|
|
582
675
|
continue
|
|
583
676
|
}
|
|
@@ -593,7 +686,15 @@ export async function syncCorpus(ctx, reqTz, state) {
|
|
|
593
686
|
await mapLimit(toRead, SCAN_CONCURRENCY, async (id) => {
|
|
594
687
|
try {
|
|
595
688
|
const snap = await query.readSession(id)
|
|
596
|
-
const contribution = scanSessionEvents(snap && snap.events ? snap.events : [], tz)
|
|
689
|
+
const contribution = scanSessionEvents(snap && snap.events ? snap.events : [], tz, snap?.inheritedEventCount)
|
|
690
|
+
contribution.evidence = captureModelEvidence(snap?.events || [], snap?.inheritedEventCount)
|
|
691
|
+
const header = snap?.header || snap?.session || records.find(rec => rec?.header?.id === id)?.header
|
|
692
|
+
contribution.cwd = safeSessionText(header?.cwd, 1024)
|
|
693
|
+
contribution.createdAt = Number.isFinite(header?.createdAt) ? header.createdAt : 0
|
|
694
|
+
contribution.title = ''
|
|
695
|
+
for (const event of snap?.events || []) {
|
|
696
|
+
if (event?.type === 'session/title') contribution.title = safeSessionText(event.data?.title, 300) || contribution.title
|
|
697
|
+
}
|
|
597
698
|
contribution.fp = fps.get(id) || null
|
|
598
699
|
contribution.syncedAt = Date.now()
|
|
599
700
|
nextSessions[id] = contribution
|
|
@@ -618,11 +719,22 @@ export async function syncCorpus(ctx, reqTz, state) {
|
|
|
618
719
|
* (windowed by `days`, optionally filtered to one `modelKey`). Pure and cheap:
|
|
619
720
|
* O(number of distinct usage dates / models), no I/O.
|
|
620
721
|
*/
|
|
621
|
-
export function
|
|
722
|
+
export function modelTestContribution(history, tz) {
|
|
723
|
+
const rows = history.usage().filter(r => r.time > 0 && r.selection)
|
|
724
|
+
const reported = rows.filter(r => r.usageReported)
|
|
725
|
+
const contribution = scanSessionEvents(reported.map(r => ({ type: 'assistant/message', time: r.time, data: { usage: r.usage, message: { source: r.selection } } })), tz)
|
|
726
|
+
contribution.audit = true
|
|
727
|
+
reported.forEach((r, i) => { contribution.usageEvents[i][8] = r.decodeMs > 0 && ['stop', 'max-tokens'].includes(r.stop) ? r.decodeMs : 0 })
|
|
728
|
+
contribution.ttftEvents = rows.filter(r => r.ttftMs !== null && ['stop', 'max-tokens'].includes(r.stop)).map(r => [r.time, r.selection.provider, r.selection.model, r.ttftMs])
|
|
729
|
+
return contribution
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
export function foldResponse(state, days, modelKey, tz, now, additional = []) {
|
|
622
733
|
// syncCorpus has already re-keyed all contributions to the requested offset.
|
|
623
734
|
const bucketTz = state.tz
|
|
624
735
|
void tz
|
|
625
736
|
const filterModel = modelKey
|
|
737
|
+
const contributions = [...Object.values(state.sessions), ...additional]
|
|
626
738
|
const fromKey = (() => {
|
|
627
739
|
if (!(days > 0)) return null
|
|
628
740
|
const fromMidnight = now - (days - 1) * 86400000
|
|
@@ -638,10 +750,15 @@ export function foldResponse(state, days, modelKey, tz, now) {
|
|
|
638
750
|
const weekSum = new Map()
|
|
639
751
|
const hourSum = new Map()
|
|
640
752
|
const modelSum = new Map()
|
|
753
|
+
const speedSum = new Map()
|
|
754
|
+
const ttftSum = new Map()
|
|
641
755
|
const totals = emptyBucket()
|
|
642
756
|
const overviewTotals = emptyBucket()
|
|
643
757
|
const overviewDays = new Set()
|
|
644
758
|
const overviewModels = new Map()
|
|
759
|
+
const overviewDaily = new Map(), recentModels = new Map()
|
|
760
|
+
const recentFrom = localDayKey(now - 6 * 86400000, bucketTz)
|
|
761
|
+
let longestTaskMs = null
|
|
645
762
|
let overviewTodayBilled = 0
|
|
646
763
|
let messages = 0
|
|
647
764
|
let sessionsWithUsage = 0
|
|
@@ -670,14 +787,48 @@ export function foldResponse(state, days, modelKey, tz, now) {
|
|
|
670
787
|
}
|
|
671
788
|
}
|
|
672
789
|
|
|
673
|
-
for (const
|
|
674
|
-
const s = state.sessions[sid]
|
|
790
|
+
for (const s of contributions) {
|
|
675
791
|
if (!s) continue
|
|
792
|
+
for (const event of s.usageEvents || []) {
|
|
793
|
+
const outputTokens = event[4], outputMs = event[8]
|
|
794
|
+
if (!Number.isFinite(outputTokens) || outputTokens < 0 || !Number.isFinite(outputMs) || outputMs <= 0) continue
|
|
795
|
+
const key = event[1] + '/' + event[2]
|
|
796
|
+
if (filterModel && key !== filterModel || fromKey && localDayKey(event[0], bucketTz) < fromKey) continue
|
|
797
|
+
let speed = speedSum.get(key)
|
|
798
|
+
if (!speed) {
|
|
799
|
+
speed = { key, provider: event[1], model: event[2], outputTokens: 0, outputMs: 0, samples: 0 }
|
|
800
|
+
speedSum.set(key, speed)
|
|
801
|
+
}
|
|
802
|
+
speed.outputTokens += outputTokens
|
|
803
|
+
speed.outputMs += outputMs
|
|
804
|
+
speed.samples++
|
|
805
|
+
}
|
|
806
|
+
for (const event of s.ttftEvents || []) {
|
|
807
|
+
if (!Number.isFinite(event[3]) || event[3] < 0) continue
|
|
808
|
+
const key = event[1] + '/' + event[2]
|
|
809
|
+
if (filterModel && key !== filterModel || fromKey && localDayKey(event[0], bucketTz) < fromKey) continue
|
|
810
|
+
let timing = ttftSum.get(key)
|
|
811
|
+
if (!timing) { timing = { ttftMs: 0, ttftSamples: 0 }; ttftSum.set(key, timing) }
|
|
812
|
+
timing.ttftMs += event[3]
|
|
813
|
+
timing.ttftSamples++
|
|
814
|
+
}
|
|
676
815
|
mergeBucket(overviewTotals, s.totals || emptyBucket())
|
|
816
|
+
if (Number.isFinite(s.longestTaskMs) && s.longestTaskMs >= 0) longestTaskMs = Math.max(longestTaskMs ?? 0, s.longestTaskMs)
|
|
677
817
|
for (const date of Object.keys(s.byDay || {})) {
|
|
678
818
|
const bucket = s.byDay[date]
|
|
679
819
|
if ((bucket.calls || 0) > 0) overviewDays.add(date)
|
|
680
820
|
if (date === todayKey) overviewTodayBilled += bucket.billed || 0
|
|
821
|
+
if (date <= todayKey) bucketInto(overviewDaily, date, bucket)
|
|
822
|
+
}
|
|
823
|
+
for (const [key, bucket] of Object.entries(s.byDayModel || {})) {
|
|
824
|
+
const sep = key.indexOf('|'), date = key.slice(0, sep)
|
|
825
|
+
if (sep < 0 || date < recentFrom || date > todayKey || !(bucket.calls > 0)) continue
|
|
826
|
+
const mk = key.slice(sep + 1), meta = s.byModel?.[mk], slash = mk.indexOf('/')
|
|
827
|
+
const model = (meta?.model || mk.slice(slash + 1)).split('/').pop(), id = model.toLowerCase()
|
|
828
|
+
let row = recentModels.get(id)
|
|
829
|
+
if (!row) { row = { model, calls: 0, providers: new Set() }; recentModels.set(id, row) }
|
|
830
|
+
row.calls += bucket.calls
|
|
831
|
+
row.providers.add(meta?.provider || mk.slice(0, slash))
|
|
681
832
|
}
|
|
682
833
|
for (const mk of Object.keys(s.byModel || {})) {
|
|
683
834
|
const model = s.byModel[mk]
|
|
@@ -719,7 +870,7 @@ export function foldResponse(state, days, modelKey, tz, now) {
|
|
|
719
870
|
bucketInto(perDay, mk, s.byDayModel[key])
|
|
720
871
|
}
|
|
721
872
|
messages += sessionMessages
|
|
722
|
-
if (sessionMessages > 0) sessionsWithUsage += 1
|
|
873
|
+
if (sessionMessages > 0 && !s.audit) sessionsWithUsage += 1
|
|
723
874
|
|
|
724
875
|
if (fromKey === null) {
|
|
725
876
|
for (const mk of Object.keys(s.byModel || {})) {
|
|
@@ -795,8 +946,7 @@ export function foldResponse(state, days, modelKey, tz, now) {
|
|
|
795
946
|
// assigned client-side per model and persisted (localStorage), so a model
|
|
796
947
|
// keeps one fixed colour across the donut and the bars regardless of rank.
|
|
797
948
|
const hourModelSum = new Map() // hour -> Map(modelKey -> bucket)
|
|
798
|
-
for (const
|
|
799
|
-
const s = state.sessions[sid]
|
|
949
|
+
for (const s of contributions) {
|
|
800
950
|
if (!s) continue
|
|
801
951
|
const hm = s.byHourModel || {}
|
|
802
952
|
for (const key of Object.keys(hm)) {
|
|
@@ -844,27 +994,105 @@ export function foldResponse(state, days, modelKey, tz, now) {
|
|
|
844
994
|
for (const [key, m] of modelSum) byModel.push({ key, provider: m.provider, model: m.model, lastTime: m.lastTime, bucket: m.bucket })
|
|
845
995
|
byModel.sort((a, b) => b.bucket.billed - a.bucket.billed)
|
|
846
996
|
|
|
997
|
+
// Compare calendar dates rather than elapsed local hours (DST and leap days).
|
|
998
|
+
const activeDays = [...overviewDays].filter(d => d <= todayKey).map(d => Date.parse(d + 'T00:00:00Z') / 86400000).filter(Number.isFinite).sort((a, b) => a - b)
|
|
999
|
+
let longestStreak = 0, streak = 0, previousDay = null
|
|
1000
|
+
for (const day of activeDays) { streak = day === previousDay + 1 ? streak + 1 : 1; longestStreak = Math.max(longestStreak, streak); previousDay = day }
|
|
1001
|
+
const activeSet = new Set(activeDays), todayOrdinal = Date.parse(todayKey + 'T00:00:00Z') / 86400000
|
|
1002
|
+
let currentStreak = 0, day = activeSet.has(todayOrdinal) ? todayOrdinal : todayOrdinal - 1
|
|
1003
|
+
while (activeSet.has(day)) { currentStreak++; day-- }
|
|
1004
|
+
let peakDailyBilled = 0, peakDay = null
|
|
1005
|
+
for (const [date, bucket] of [...overviewDaily.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
|
|
1006
|
+
if (bucket.calls > 0 && bucket.billed >= peakDailyBilled) { peakDailyBilled = bucket.billed; peakDay = date }
|
|
1007
|
+
}
|
|
1008
|
+
const recentModel = [...recentModels.values()].sort((a, b) => b.calls - a.calls || a.model.localeCompare(b.model))[0]
|
|
1009
|
+
const allInput = overviewTotals.inputTokens + overviewTotals.cacheReadTokens + overviewTotals.cacheWriteTokens
|
|
847
1010
|
const overview = {
|
|
848
1011
|
todayBilled: overviewTodayBilled,
|
|
849
1012
|
usedDays: overviewDays.size,
|
|
850
1013
|
totalBilled: overviewTotals.billed,
|
|
1014
|
+
totals: serializeBucket(overviewTotals),
|
|
851
1015
|
models: [...overviewModels.values()].sort((a, b) => b.billed - a.billed),
|
|
1016
|
+
cacheHitRate: allInput > 0 ? overviewTotals.cacheReadTokens / allInput : null,
|
|
1017
|
+
peakDailyBilled, peakDay, longestTaskMs, currentStreak, longestStreak,
|
|
1018
|
+
recentModel: recentModel ? { ...recentModel, providers: [...recentModel.providers].sort(), days: 7 } : null,
|
|
852
1019
|
}
|
|
853
|
-
|
|
1020
|
+
const tokenSpeeds = [...speedSum.values()].map((speed) => {
|
|
1021
|
+
const timing = ttftSum.get(speed.key) || { ttftMs: 0, ttftSamples: 0 }
|
|
1022
|
+
return { ...speed, tokensPerSecond: speed.outputTokens * 1000 / speed.outputMs, ...timing,
|
|
1023
|
+
averageTtftMs: timing.ttftSamples ? timing.ttftMs / timing.ttftSamples : null }
|
|
1024
|
+
})
|
|
1025
|
+
.sort((a, b) => b.tokensPerSecond - a.tokensPerSecond || b.samples - a.samples || a.key.localeCompare(b.key))
|
|
1026
|
+
return { totals, messages, sessionsWithUsage, byDay, byDayModels, byWeek, byHour, byHourModels, byModel, tokenSpeeds, overview, firstTime, lastTime }
|
|
854
1027
|
}
|
|
855
1028
|
|
|
856
1029
|
/* ------------------------------------------------------------------ */
|
|
857
1030
|
/* Cordis plugin `apply` */
|
|
858
1031
|
/* ------------------------------------------------------------------ */
|
|
859
1032
|
|
|
1033
|
+
function safeSessionText(value, max) {
|
|
1034
|
+
return typeof value === 'string' ? value.replace(/[\u0000-\u001f\u007f]/g, '').trim().slice(0, max) : ''
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
/** Session-level totals obey the same date/model filters as usage buckets.
|
|
1038
|
+
* Only explicit title/path metadata and measured amounts reach the renderer. */
|
|
1039
|
+
export function foldSessionRows(state, options = {}, now = Date.now()) {
|
|
1040
|
+
const days = Math.max(0, Math.min(3650, Math.round(Number(options.days) || 0)))
|
|
1041
|
+
const from = days ? localDayKey(now - (days - 1) * 86400000, state.tz) : ''
|
|
1042
|
+
const query = safeSessionText(options.q, 200).toLowerCase()
|
|
1043
|
+
const model = options.model || ''
|
|
1044
|
+
const selectedDay = /^\d{4}-\d{2}-\d{2}$/.test(options.day || '') ? options.day : ''
|
|
1045
|
+
const projects = new Set(), rows = []
|
|
1046
|
+
for (const [id, session] of Object.entries(state.sessions)) {
|
|
1047
|
+
if (!session.withUsage) continue
|
|
1048
|
+
const cwd = safeSessionText(session.cwd, 1024)
|
|
1049
|
+
if (cwd) projects.add(cwd)
|
|
1050
|
+
if (options.project && options.project !== cwd) continue
|
|
1051
|
+
const title = safeSessionText(session.title, 300)
|
|
1052
|
+
if (query && !(title + ' ' + cwd + ' ' + id).toLowerCase().includes(query)) continue
|
|
1053
|
+
const totals = emptyBucket()
|
|
1054
|
+
if (from || selectedDay) {
|
|
1055
|
+
const buckets = model ? session.byDayModel : session.byDay
|
|
1056
|
+
for (const [key, bucket] of Object.entries(buckets || {})) {
|
|
1057
|
+
const date = key.slice(0, 10)
|
|
1058
|
+
if (from && date < from || selectedDay && date !== selectedDay || model && key.slice(11) !== model) continue
|
|
1059
|
+
mergeBucket(totals, bucket)
|
|
1060
|
+
}
|
|
1061
|
+
} else if (model) mergeBucket(totals, session.byModel?.[model]?.bucket || emptyBucket())
|
|
1062
|
+
else mergeBucket(totals, session.totals)
|
|
1063
|
+
if (!totals.calls) continue
|
|
1064
|
+
const models = Object.entries(session.byModel || {}).filter(([key]) => !model || key === model)
|
|
1065
|
+
.map(([key, value]) => ({ key, provider: value.provider, model: value.model }))
|
|
1066
|
+
let lastTime = Math.max(0, ...Object.entries(session.byModel || {}).filter(([key]) => !model || key === model).map(([, value]) => value.lastTime || 0))
|
|
1067
|
+
if (Array.isArray(session.usageEvents)) {
|
|
1068
|
+
lastTime = 0
|
|
1069
|
+
for (const event of session.usageEvents) {
|
|
1070
|
+
const day = localDayKey(event[0], state.tz)
|
|
1071
|
+
if ((!from || day >= from) && (!selectedDay || day === selectedDay) && (!model || event[1] + '/' + event[2] === model)) lastTime = Math.max(lastTime, event[0])
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
rows.push({ id, title, cwd, createdAt: session.createdAt || 0, lastTime,
|
|
1075
|
+
retained: !!session.retained, models, ...serializeBucket(totals) })
|
|
1076
|
+
}
|
|
1077
|
+
const sort = options.sort === 'tokens' ? 'tokens' : 'recent'
|
|
1078
|
+
rows.sort((a, b) => (sort === 'tokens' ? b.billed - a.billed : b.lastTime - a.lastTime) || a.id.localeCompare(b.id))
|
|
1079
|
+
const pageSize = Math.min(100, Math.max(1, Math.round(Number(options.pageSize) || 20)))
|
|
1080
|
+
const pages = Math.max(1, Math.ceil(rows.length / pageSize))
|
|
1081
|
+
const page = Math.min(pages, Math.max(1, Math.round(Number(options.page) || 1)))
|
|
1082
|
+
return { rows: rows.slice((page - 1) * pageSize, page * pageSize), total: rows.length, page, pageSize, pages,
|
|
1083
|
+
projects: [...projects].sort(), sort, model: model || null, day: selectedDay || null }
|
|
1084
|
+
}
|
|
1085
|
+
|
|
860
1086
|
export function apply(ctx) {
|
|
861
1087
|
if (ctx.get('profileContext')?.name !== 'desktop') return
|
|
862
1088
|
|
|
863
1089
|
const readProviderQuotas = createProviderQuotaReader(ctx)
|
|
1090
|
+
const modelTestHistory = createModelTestHistory(join(resolveHome(), 'storages', 'usage-stats-model-tests.json'))
|
|
1091
|
+
const modelMatchRunner = createModelMatchRunner(ctx, { path: join(resolveHome(), 'storages', 'usage-stats-model-baselines.json'), onUpdate: job => modelTestHistory.record(job) })
|
|
864
1092
|
const quotaControls = createQuotaControls(ctx, {
|
|
865
1093
|
path: join(resolveHome(), 'storages', 'usage-stats-controls.json'),
|
|
866
1094
|
sources: {
|
|
867
|
-
providers: (excluded) => readProviderQuotas(true, excluded),
|
|
1095
|
+
providers: (excluded, provider) => readProviderQuotas(true, excluded, provider),
|
|
868
1096
|
workbuddy: workBuddyQuotaSource(ctx),
|
|
869
1097
|
'workbuddy-ai': workBuddyQuotaSource(ctx, undefined, 'workbuddy-ai'),
|
|
870
1098
|
},
|
|
@@ -980,7 +1208,7 @@ export function apply(ctx) {
|
|
|
980
1208
|
}
|
|
981
1209
|
if (state.tz !== requestedTz) throw new Error('requested timezone is not ready')
|
|
982
1210
|
const now = Date.now()
|
|
983
|
-
const data = foldResponse(state, days, modelKey, state.tz, now)
|
|
1211
|
+
const data = foldResponse(state, days, modelKey, state.tz, now, [modelTestContribution(modelTestHistory, state.tz)])
|
|
984
1212
|
const stale = syncPromise !== null || state.partial || now - (state.syncedAt || 0) > CACHE_TTL_MS
|
|
985
1213
|
writeJson(res, 200, {
|
|
986
1214
|
ok: true,
|
|
@@ -1000,9 +1228,11 @@ export function apply(ctx) {
|
|
|
1000
1228
|
byHour: data.byHour.map((h) => ({ hour: h.hour, ...serializeBucket(h.bucket) })),
|
|
1001
1229
|
byHourModels: data.byHourModels,
|
|
1002
1230
|
byModel: data.byModel.map((m) => ({ key: m.key, provider: m.provider, model: m.model, lastTime: m.lastTime, ...serializeBucket(m.bucket) })),
|
|
1231
|
+
tokenSpeeds: data.tokenSpeeds,
|
|
1003
1232
|
syncedAt: state.syncedAt || 0,
|
|
1004
1233
|
stale,
|
|
1005
1234
|
partial: state.partial,
|
|
1235
|
+
auditUsageWarning: modelTestHistory.warning(),
|
|
1006
1236
|
scanMs: Date.now() - t0,
|
|
1007
1237
|
})
|
|
1008
1238
|
} catch {
|
|
@@ -1021,10 +1251,10 @@ export function apply(ctx) {
|
|
|
1021
1251
|
if (!guard(req, res, 'GET')) return
|
|
1022
1252
|
const fresh = readQuery(req).get('fresh') === '1'
|
|
1023
1253
|
try {
|
|
1024
|
-
writeJson(res, 200, await quotaControls.read('providers', fresh))
|
|
1025
|
-
} catch {
|
|
1254
|
+
writeJson(res, 200, await quotaControls.read('providers', fresh, readQuery(req).get('provider')))
|
|
1255
|
+
} catch (error) {
|
|
1026
1256
|
// This route never sends credential or upstream error text to the renderer.
|
|
1027
|
-
writeJson(res,
|
|
1257
|
+
writeJson(res, error instanceof QuotaControlError ? 400 : 500, { ok: false, error: error instanceof QuotaControlError ? error.message : '配额查询暂时不可用,请稍后重试。' })
|
|
1028
1258
|
}
|
|
1029
1259
|
},
|
|
1030
1260
|
}),
|
|
@@ -1041,13 +1271,13 @@ export function apply(ctx) {
|
|
|
1041
1271
|
} catch { writeJson(res, 403, { ok: false, error: '请求来源不受信任。' }); return false }
|
|
1042
1272
|
return true
|
|
1043
1273
|
}
|
|
1044
|
-
const readControlBody = async (req) => {
|
|
1274
|
+
const readControlBody = async (req, limit = 32_768) => {
|
|
1045
1275
|
if (!String(req.headers['content-type'] || '').startsWith('application/json')) throw new QuotaControlError('请使用 JSON 请求。')
|
|
1046
1276
|
let bytes = 0
|
|
1047
1277
|
const chunks = []
|
|
1048
1278
|
for await (const chunk of req) {
|
|
1049
1279
|
bytes += Buffer.byteLength(chunk)
|
|
1050
|
-
if (bytes >
|
|
1280
|
+
if (bytes > limit) throw new QuotaControlError(limit === 32_768 ? '请求内容超过 32 KB。' : '基线文件超过 1 MB。')
|
|
1051
1281
|
chunks.push(Buffer.from(chunk))
|
|
1052
1282
|
}
|
|
1053
1283
|
try { return JSON.parse(Buffer.concat(chunks).toString('utf8')) } catch { throw new QuotaControlError('请求内容不是有效 JSON。') }
|
|
@@ -1056,7 +1286,10 @@ export function apply(ctx) {
|
|
|
1056
1286
|
kind: 'exact', path: '/api/dsh-usage-stats/' + path,
|
|
1057
1287
|
handler: async (req, res) => {
|
|
1058
1288
|
if (!trustedControlRequest(req, res)) return
|
|
1059
|
-
try { await handler(req, res) } catch (error) {
|
|
1289
|
+
try { await handler(req, res) } catch (error) {
|
|
1290
|
+
const expected = error instanceof QuotaControlError || error instanceof ModelMatchError
|
|
1291
|
+
writeJson(res, expected ? 400 : 500, { ok: false, error: expected ? error.message : '操作失败,请稍后重试。' })
|
|
1292
|
+
}
|
|
1060
1293
|
},
|
|
1061
1294
|
}), 'dsh-usage-stats: ' + path)
|
|
1062
1295
|
registerControl('controls', async (req, res) => {
|
|
@@ -1071,6 +1304,67 @@ export function apply(ctx) {
|
|
|
1071
1304
|
const body = await readControlBody(req)
|
|
1072
1305
|
writeJson(res, 200, await quotaControls.test(body.provider, body.template))
|
|
1073
1306
|
})
|
|
1307
|
+
registerControl('sessions', async (req, res) => {
|
|
1308
|
+
if (!guard(req, res, 'GET')) return
|
|
1309
|
+
const params = readQuery(req), tzParam = params.get('tz')
|
|
1310
|
+
const tz = tzParam === null ? SYSTEM_TZ : clampTz(tzParam)
|
|
1311
|
+
await ensureLoaded()
|
|
1312
|
+
if (state.tz !== tz) await sync(tz)
|
|
1313
|
+
else if (params.get('fresh') === '1' || state.partial || Date.now() - state.syncedAt > CACHE_TTL_MS) await syncWithBudget(tz)
|
|
1314
|
+
writeJson(res, 200, { ok: true, ...foldSessionRows(state, Object.fromEntries(params)), syncedAt: state.syncedAt,
|
|
1315
|
+
stale: syncPromise !== null || state.partial || Date.now() - state.syncedAt > CACHE_TTL_MS, partial: state.partial })
|
|
1316
|
+
})
|
|
1317
|
+
registerControl('model-match', async (req, res) => {
|
|
1318
|
+
if (!guard(req, res, 'GET')) return
|
|
1319
|
+
const params = readQuery(req), sessionId = params.get('session') || ''
|
|
1320
|
+
const latestJob = () => {
|
|
1321
|
+
const id = params.get('job') || undefined
|
|
1322
|
+
try {
|
|
1323
|
+
return modelMatchRunner.status(id, sessionId) || modelTestHistory.latest(sessionId)
|
|
1324
|
+
|| (!id && (modelMatchRunner.status() || modelTestHistory.latest())) || null
|
|
1325
|
+
}
|
|
1326
|
+
catch (error) { const stored = id && modelTestHistory.get(id); if (stored) return stored; throw error }
|
|
1327
|
+
}
|
|
1328
|
+
if (params.get('jobOnly') === '1') { writeJson(res, 200, { ok: true, job: latestJob() }); return }
|
|
1329
|
+
await ensureLoaded()
|
|
1330
|
+
// Analyze a fresh local snapshot for this one session. No inference calls.
|
|
1331
|
+
if (sessionId && !state.sessions[sessionId]?.retained) {
|
|
1332
|
+
try {
|
|
1333
|
+
const snapshot = await ctx.get('sessionQuery').readSession(sessionId)
|
|
1334
|
+
const evidence = captureModelEvidence(snapshot?.events || [], snapshot?.inheritedEventCount)
|
|
1335
|
+
const requested = modelSelection({ provider: params.get('provider'), model: params.get('model'), reasoningEffort: params.get('reasoningEffort') })
|
|
1336
|
+
writeJson(res, 200, { ok: true, sessionId, analysis: analyzeModelEvidence(evidence, requested), job: latestJob() })
|
|
1337
|
+
return
|
|
1338
|
+
} catch (error) { if (error instanceof ModelMatchError) throw error }
|
|
1339
|
+
}
|
|
1340
|
+
const requested = modelSelection({ provider: params.get('provider'), model: params.get('model'), reasoningEffort: params.get('reasoningEffort') })
|
|
1341
|
+
writeJson(res, 200, { ok: true, sessionId, retained: !!state.sessions[sessionId]?.retained,
|
|
1342
|
+
analysis: analyzeModelEvidence(state.sessions[sessionId]?.evidence, requested), job: latestJob() })
|
|
1343
|
+
})
|
|
1344
|
+
registerControl('model-test', async (req, res) => {
|
|
1345
|
+
if (!guard(req, res, 'POST')) return
|
|
1346
|
+
const body = await readControlBody(req)
|
|
1347
|
+
// Starting requires a real readable session; cancel never sends a request.
|
|
1348
|
+
if (body.action === 'cancel') { writeJson(res, 200, { ok: true, job: modelMatchRunner.cancel(body.id) }); return }
|
|
1349
|
+
const records = await ctx.get('sessionQuery').listSessions()
|
|
1350
|
+
if (!(records || []).some(rec => rec?.header?.id === body.sessionId)) throw new ModelMatchError('会话已不可用,请重新选择。')
|
|
1351
|
+
if (body.action === 'plan') { writeJson(res, 200, { ok: true, quote: await modelMatchRunner.plan(body) }); return }
|
|
1352
|
+
writeJson(res, 200, { ok: true, job: modelMatchRunner.start(body) })
|
|
1353
|
+
})
|
|
1354
|
+
registerControl('model-baselines', async (req, res) => {
|
|
1355
|
+
if (req.method === 'GET') {
|
|
1356
|
+
const id = readQuery(req).get('export')
|
|
1357
|
+
writeJson(res, 200, id ? { ok: true, reference: modelMatchRunner.exportReference(id) } : { ok: true, ...modelMatchRunner.catalog() })
|
|
1358
|
+
return
|
|
1359
|
+
}
|
|
1360
|
+
if (!guard(req, res, 'POST')) return
|
|
1361
|
+
const body = await readControlBody(req, 1_048_576)
|
|
1362
|
+
if (body.action === 'remove') await modelMatchRunner.removeReference(body.id)
|
|
1363
|
+
else if (body.action === 'import') await modelMatchRunner.importReference(body.reference)
|
|
1364
|
+
else throw new ModelMatchError('请选择导入或移除基线。')
|
|
1365
|
+
writeJson(res, 200, { ok: true, ...modelMatchRunner.catalog() })
|
|
1366
|
+
})
|
|
1367
|
+
ctx.effect(() => () => modelMatchRunner.dispose(), 'dsh-usage-stats: model test cleanup')
|
|
1074
1368
|
for (const source of ['workbuddy', 'workbuddy-ai']) {
|
|
1075
1369
|
registerControl(source, async (req, res) => {
|
|
1076
1370
|
if (guard(req, res, 'GET')) writeJson(res, 200, await quotaControls.read(source, readQuery(req).get('fresh') === '1'))
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/** Seeded, locally graded tasks. Scores describe these tasks only, never model identity. */
|
|
2
|
+
import { binomialTail } from './model-fingerprints.js'
|
|
3
|
+
export function capabilityTasks(seed) {
|
|
4
|
+
let state = seed >>> 0
|
|
5
|
+
const rand = max => { state = (Math.imul(state, 1664525) + 1013904223) >>> 0; return state % max }
|
|
6
|
+
const tasks = []
|
|
7
|
+
const add = (domain, prompt, answer) => tasks.push({ id: `${domain}-${tasks.length}`, domain,
|
|
8
|
+
prompt: prompt + '\nReturn only JSON with exactly one key "answer". Do not use tools.',
|
|
9
|
+
check(text) { const value = JSON.parse(text.trim()); return Object.keys(value).length === 1 && JSON.stringify(value.answer) === JSON.stringify(answer) } })
|
|
10
|
+
for (let i = 0; i < 4; i++) {
|
|
11
|
+
const a = rand(17) + 2, b = rand(23) + 1, turns = rand(16) + 30
|
|
12
|
+
let x = rand(300), initial = x, sum = 0
|
|
13
|
+
for (let j = 0; j < turns; j++) { x = (x * a + b + j) % 997; sum += j % 3 === 0 ? -x : x }
|
|
14
|
+
add('code', `Evaluate this JavaScript without executing it. What is sum?\nlet x=${initial}, sum=0; for(let j=0;j<${turns};j++){x=(x*${a}+${b}+j)%997;sum+=j%3===0?-x:x;}`, sum)
|
|
15
|
+
const edges = [], costs = Array(11).fill(Infinity); costs[0] = 0
|
|
16
|
+
for (let u = 0; u < 10; u++) for (let v = u + 1; v <= Math.min(10, u + 4); v++) edges.push([u, v, rand(31) + 1])
|
|
17
|
+
for (let u = 0; u < 10; u++) for (const [from, to, weight] of edges) if (from === u) costs[to] = Math.min(costs[to], costs[from] + weight)
|
|
18
|
+
add('reasoning', `Find the minimum total weight of a directed path from node 0 to node 10. Edges [from,to,weight]: ${JSON.stringify(edges)}`, costs[10])
|
|
19
|
+
const entries = Array.from({ length: 160 }, (_, n) => ({ key: `record_${n}`, priority: rand(1000), value: rand(100_000) }))
|
|
20
|
+
const chosen = rand(150) + 5, key = entries[chosen].key
|
|
21
|
+
add('context', `Read the following records. For key ${key}, return the value in the LAST occurrence. Ignore priorities.\n${JSON.stringify(entries.concat({ key, priority: 9999, value: entries[chosen].value + 17 }))}`, entries[chosen].value + 17)
|
|
22
|
+
const intervals = Array.from({ length: 16 }, () => { const start = rand(30); return [start, start + rand(9) + 1, rand(40) + 1] }).sort((a, b) => a[1] - b[1])
|
|
23
|
+
const dp = [0]
|
|
24
|
+
intervals.forEach(([start, , reward], j) => { let previous = j - 1; while (previous >= 0 && intervals[previous][1] > start) previous--; dp.push(Math.max(dp[j], reward + dp[previous + 1])) })
|
|
25
|
+
add('constraints', `Choose non-overlapping jobs to maximize reward. A job ending at t is compatible with a job starting at t. Return the maximum reward. Jobs [start,end,reward]: ${JSON.stringify(intervals)}`, dp.at(-1))
|
|
26
|
+
}
|
|
27
|
+
return tasks
|
|
28
|
+
}
|
|
29
|
+
export function scoreCapabilities(outcomes, reference, complete = true) {
|
|
30
|
+
const valid = outcomes.filter(v => v === 0 || v === 1), passed = valid.filter(v => v === 1).length
|
|
31
|
+
const result = { verdict: 'current-sample', valid: valid.length, passed, count: 16, paired: 0, losses: 0, gains: 0, pValue: null }
|
|
32
|
+
if (!complete || valid.length < 12) return { ...result, verdict: 'insufficient-evidence' }
|
|
33
|
+
if (!reference) return result
|
|
34
|
+
outcomes.forEach((value, i) => { const ref = reference[i]; if ((value === 0 || value === 1) && (ref === 0 || ref === 1)) {
|
|
35
|
+
result.paired++; if (ref === 1 && value === 0) result.losses++; if (ref === 0 && value === 1) result.gains++
|
|
36
|
+
} })
|
|
37
|
+
if (result.paired < 12) return { ...result, verdict: 'insufficient-evidence' }
|
|
38
|
+
const discordant = result.losses + result.gains
|
|
39
|
+
result.pValue = discordant ? binomialTail(result.losses, discordant, 0.5) : 1
|
|
40
|
+
return { ...result, verdict: result.pValue < 0.05 ? 'significant-decline' : 'no-significant-decline' }
|
|
41
|
+
}
|