@aiwayds/dsh-topics-memory 0.18.2 → 0.20.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 +84 -1
- package/README.zh.md +77 -1
- package/lib/commands.js +3 -0
- package/lib/config.d.ts +62 -1
- package/lib/config.js +51 -2
- package/lib/consolidate.d.ts +39 -1
- package/lib/consolidate.js +195 -1
- package/lib/index.d.ts +14 -0
- package/lib/index.js +86 -13
- package/lib/jev/backends.d.ts +34 -0
- package/lib/jev/backends.js +46 -0
- package/lib/jev/client.d.ts +98 -0
- package/lib/jev/client.js +225 -0
- package/lib/jev/dual.d.ts +48 -0
- package/lib/jev/dual.js +47 -0
- package/lib/jev/gate.d.ts +50 -0
- package/lib/jev/gate.js +125 -0
- package/lib/jev/log.d.ts +73 -0
- package/lib/jev/log.js +121 -0
- package/lib/jev/thresholds.d.ts +21 -0
- package/lib/jev/thresholds.js +27 -0
- package/lib/quality.d.ts +60 -2
- package/lib/quality.js +290 -11
- package/package.json +2 -2
- package/skills/dsh-topics-memory-laya/SKILL.md +84 -0
- package/src/commands.ts +2 -0
- package/src/config.ts +59 -2
- package/src/consolidate.ts +238 -1
- package/src/index.ts +95 -13
- package/src/jev/backends.ts +69 -0
- package/src/jev/client.ts +336 -0
- package/src/jev/dual.ts +73 -0
- package/src/jev/gate.ts +151 -0
- package/src/jev/log.ts +194 -0
- package/src/jev/thresholds.ts +31 -0
- package/src/quality.ts +332 -11
package/src/jev/gate.ts
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Outbound secret gate (design 2026-09-25 §3.3) — self-contained port of the
|
|
3
|
+
* dsh-jev-mcp `server.mjs` mechanism. The COMPLETE serialized request body
|
|
4
|
+
* (model, state, questions — byte-identical to what goes on the wire) is
|
|
5
|
+
* scanned for credential patterns BEFORE anything leaves the process; a hit
|
|
6
|
+
* aborts the call with a structured descriptor that never echoes the secret.
|
|
7
|
+
*
|
|
8
|
+
* SECURITY INVARIANT: hit descriptors carry static metadata only (pattern
|
|
9
|
+
* name, offset, ≤10 printable chars before the hit) — never the matched
|
|
10
|
+
* text, and never text at or after the hit point.
|
|
11
|
+
*
|
|
12
|
+
* @module jev/gate
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { readFileSync } from 'node:fs'
|
|
16
|
+
|
|
17
|
+
export interface SecretGateHit {
|
|
18
|
+
pattern: string
|
|
19
|
+
offset: number
|
|
20
|
+
context: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Minimal logger surface (both sinks optional — bare harnesses have none);
|
|
24
|
+
* the gate only ever warns, and a broken logger must not break the gate. */
|
|
25
|
+
export interface GateLogger {
|
|
26
|
+
warn?: (m: string) => void
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function safeWarn(log: GateLogger | undefined, message: string): void {
|
|
30
|
+
try {
|
|
31
|
+
log?.warn?.(message)
|
|
32
|
+
} catch {
|
|
33
|
+
// contained — diagnostics never take the feature down
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Prefix-style patterns require a 20+ char continuation so that merely
|
|
38
|
+
// DISCUSSING a prefix (e.g. "set OPENROUTER_API_KEY to an sk-or-v1- key")
|
|
39
|
+
// never trips the gate — only an actual long token does.
|
|
40
|
+
export const BUILTIN_PATTERNS: ReadonlyArray<{ name: string; re: RegExp }> = [
|
|
41
|
+
{ name: 'jwt', re: /eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/ },
|
|
42
|
+
{ name: 'openrouter-key', re: /sk-or-v1-[A-Za-z0-9_-]{20,}/ },
|
|
43
|
+
{ name: 'anthropic-key', re: /sk-ant-[A-Za-z0-9_-]{20,}/ },
|
|
44
|
+
{ name: 'openai-project-key', re: /sk-proj-[A-Za-z0-9_-]{20,}/ },
|
|
45
|
+
{ name: 'openai-sk-key', re: /sk-[A-Za-z0-9]{20,}/ },
|
|
46
|
+
{ name: 'github-token', re: /ghp_[A-Za-z0-9]{20,}/ },
|
|
47
|
+
{ name: 'github-pat', re: /github_pat_[A-Za-z0-9_]{20,}/ },
|
|
48
|
+
{ name: 'gitlab-token', re: /glpat-[A-Za-z0-9_-]{20,}/ },
|
|
49
|
+
{ name: 'slack-token', re: /xox[baprs]-[A-Za-z0-9-]{20,}/ },
|
|
50
|
+
{ name: 'aws-access-key-id', re: /AKIA[0-9A-Z]{16}/ },
|
|
51
|
+
{ name: 'aws-temp-key-id', re: /ASIA[0-9A-Z]{16}/ },
|
|
52
|
+
{ name: 'google-api-key', re: /AIza[A-Za-z0-9_-]{20,}/ },
|
|
53
|
+
{ name: 'private-key-block', re: /-----BEGIN [A-Z ]*PRIVATE KEY-----/ },
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
const MAX_SECRET_LINE = 512 // longer lines WARN; matching ALWAYS uses the full line
|
|
57
|
+
const MIN_EXTERNAL_SECRET = 8 // shorter entries would cause runaway false hits
|
|
58
|
+
|
|
59
|
+
/** Literal secrets from the external list file (JEV_SECRET_FILE semantics:
|
|
60
|
+
* one entry per line, ≥8 chars, `#` comments skipped, matched at FULL
|
|
61
|
+
* length — a >512-char line warns but is never truncated, or everything
|
|
62
|
+
* past char 512 would silently escape the gate). */
|
|
63
|
+
export function loadExternalSecrets(file: string, log?: GateLogger): string[] {
|
|
64
|
+
if (file === '') return []
|
|
65
|
+
let raw: string
|
|
66
|
+
try {
|
|
67
|
+
raw = readFileSync(file, 'utf8')
|
|
68
|
+
} catch (err) {
|
|
69
|
+
safeWarn(log, `topics jev: cannot read jevSecretFile (${file}): ${err instanceof Error ? err.message : String(err)}; continuing without it.`)
|
|
70
|
+
return []
|
|
71
|
+
}
|
|
72
|
+
const secrets: string[] = []
|
|
73
|
+
raw.split(/\r?\n/).forEach((line, i) => {
|
|
74
|
+
const s = line.trim()
|
|
75
|
+
if (s === '' || s.startsWith('#')) return
|
|
76
|
+
if (s.length < MIN_EXTERNAL_SECRET) {
|
|
77
|
+
safeWarn(log, `topics jev: jevSecretFile line ${i + 1} shorter than ${MIN_EXTERNAL_SECRET} chars, skipped (false-positive guard).`)
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
if (s.length > MAX_SECRET_LINE) {
|
|
81
|
+
safeWarn(log, `topics jev: jevSecretFile line ${i + 1} is ${s.length} chars (> ${MAX_SECRET_LINE}); matching uses the FULL line (no truncation).`)
|
|
82
|
+
}
|
|
83
|
+
secrets.push(s)
|
|
84
|
+
})
|
|
85
|
+
return secrets
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// One-time-per-path load, mirroring the reference implementation's
|
|
89
|
+
// load-once-at-startup while staying compatible with the volatile
|
|
90
|
+
// `jevSecretFile` config: an unchanged path is served from the cache (no
|
|
91
|
+
// re-read), a swapped path reloads on the next call. `reloadSecrets` forces
|
|
92
|
+
// a re-read — the `/topics set jev-secret-file` wiring calls it so an
|
|
93
|
+
// in-place content change is picked up too.
|
|
94
|
+
let cache: { file: string; secrets: string[] } = { file: '', secrets: [] }
|
|
95
|
+
|
|
96
|
+
export function currentSecrets(file: string, log?: GateLogger): string[] {
|
|
97
|
+
if (cache.file !== file) reloadSecrets(file, log)
|
|
98
|
+
return cache.secrets
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function reloadSecrets(file: string, log?: GateLogger): string[] {
|
|
102
|
+
cache = { file, secrets: loadExternalSecrets(file, log) }
|
|
103
|
+
return cache.secrets
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Redacted context: at most 10 printable chars BEFORE the hit plus an
|
|
108
|
+
* explicit [REDACTED:<pattern>] marker. Content at or after the hit point is
|
|
109
|
+
* NEVER shown — when a prefix regex under-matches, the secret tail sits
|
|
110
|
+
* right there. Belt and suspenders: if the matched text or any known
|
|
111
|
+
* external secret still appears in the context, the whole context collapses
|
|
112
|
+
* to '[REDACTED]'.
|
|
113
|
+
*/
|
|
114
|
+
export function describeHit(
|
|
115
|
+
pattern: string,
|
|
116
|
+
offset: number,
|
|
117
|
+
matched: string,
|
|
118
|
+
payload: string,
|
|
119
|
+
externalSecrets: readonly string[] = [],
|
|
120
|
+
): SecretGateHit {
|
|
121
|
+
const before = payload.slice(Math.max(0, offset - 10), offset).replace(/[^\x20-\x7e]/g, '.') // keep it printable
|
|
122
|
+
const context = `${before}[REDACTED:${pattern}]`
|
|
123
|
+
if (context.includes(matched) || externalSecrets.some((s) => s !== '' && context.includes(s))) {
|
|
124
|
+
return { pattern, offset, context: '[REDACTED]' }
|
|
125
|
+
}
|
|
126
|
+
return { pattern, offset, context }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Scan the serialized outbound payload for secrets.
|
|
131
|
+
* @param payload JSON.stringify({model, state, questions}) — the exact wire body
|
|
132
|
+
* @param externalSecrets literal secrets from the external list (cached via currentSecrets)
|
|
133
|
+
* @param externalSource label for hit descriptors (the list file path)
|
|
134
|
+
*/
|
|
135
|
+
export function scanForSecrets(
|
|
136
|
+
payload: string,
|
|
137
|
+
externalSecrets: readonly string[] = [],
|
|
138
|
+
externalSource = '',
|
|
139
|
+
): SecretGateHit | null {
|
|
140
|
+
for (const { name, re } of BUILTIN_PATTERNS) {
|
|
141
|
+
const m = re.exec(payload)
|
|
142
|
+
if (m) return describeHit(name, m.index, m[0], payload, externalSecrets)
|
|
143
|
+
}
|
|
144
|
+
for (const secret of externalSecrets) {
|
|
145
|
+
const idx = payload.indexOf(secret)
|
|
146
|
+
if (idx !== -1) {
|
|
147
|
+
return describeHit(externalSource !== '' ? `external(${externalSource})` : 'external', idx, secret, payload, externalSecrets)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return null
|
|
151
|
+
}
|
package/src/jev/log.ts
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* decisions.jsonl — System One usage/optimization telemetry (design
|
|
3
|
+
* 2026-09-25 §6), always on while jevEnabled is on. Lives beside the ilog at
|
|
4
|
+
* `<bundle>/meta/decisions.jsonl` under the same discipline: 512KB
|
|
5
|
+
* auto-compaction keeping the most recent quarter, line JSON, and write
|
|
6
|
+
* failures swallowed silently — fail-open extends to the stats itself.
|
|
7
|
+
*
|
|
8
|
+
* REDACTION INVARIANT (§6.1): metadata and probabilities only — slug/pair
|
|
9
|
+
* hash/question type/score/latency/token count. NEVER the state text or
|
|
10
|
+
* conclusion bodies. Writers copy an explicit field whitelist, so extra
|
|
11
|
+
* fields on caller objects cannot leak into the file.
|
|
12
|
+
*
|
|
13
|
+
* @module jev/log
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { appendFile, mkdir, readFile, rename, writeFile } from 'node:fs/promises'
|
|
17
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
18
|
+
import { dirname, join } from 'node:path'
|
|
19
|
+
import { metaDir, resolveBundleRoot } from '../paths.ts'
|
|
20
|
+
import type { JevBand } from './thresholds.ts'
|
|
21
|
+
|
|
22
|
+
export type JevLane = 'slowlane-rerank' | 'consolidate-prefilter' | 'fastgate-shadow'
|
|
23
|
+
|
|
24
|
+
export type JevOutcome =
|
|
25
|
+
| 'ok'
|
|
26
|
+
| 'timeout'
|
|
27
|
+
| 'network'
|
|
28
|
+
| 'http_4xx'
|
|
29
|
+
| 'http_5xx'
|
|
30
|
+
| 'bad_json'
|
|
31
|
+
| 'secret_gate_blocked'
|
|
32
|
+
| 'missing_key'
|
|
33
|
+
/** Local pre-flight refusal (e.g. questions violate the decisions/openrouter
|
|
34
|
+
* wire contract) — nothing was sent. Reserved outcome beyond §6.2's v1 list. */
|
|
35
|
+
| 'invalid_request'
|
|
36
|
+
|
|
37
|
+
export interface JevUsage {
|
|
38
|
+
input_tokens: number
|
|
39
|
+
output_tokens: number
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Call layer (§6.2): one row per HTTP call, failures included — the
|
|
43
|
+
* fallback rate is the health metric, so failed calls must be counted. */
|
|
44
|
+
export interface JevCallRecord {
|
|
45
|
+
at: string
|
|
46
|
+
lane: JevLane
|
|
47
|
+
backend: string
|
|
48
|
+
model: string
|
|
49
|
+
questionCount: number
|
|
50
|
+
stateChars: number
|
|
51
|
+
latencyMs: number
|
|
52
|
+
usage: JevUsage | null
|
|
53
|
+
outcome: JevOutcome
|
|
54
|
+
fallback: boolean
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Verdict layer (§6.2): one row per question, batch requests expanded.
|
|
58
|
+
* `agree` reconciles the fast-gate shadow against the lexical disposition
|
|
59
|
+
* (wouldBlock = lexical let it through but jev landed in the veto band —
|
|
60
|
+
* the other half of the calibration goldmine). */
|
|
61
|
+
export interface JevVerdictRecord {
|
|
62
|
+
at: string
|
|
63
|
+
lane: JevLane
|
|
64
|
+
questionId: string
|
|
65
|
+
qtype: 'noul' | 'choice' | 'score'
|
|
66
|
+
/** Candidate reference without content: `slug:xxx` | `pair:hash3`. */
|
|
67
|
+
ref: string
|
|
68
|
+
/** §6.2 digest — stable key for the repeat-decision-rate analysis. */
|
|
69
|
+
digest: string
|
|
70
|
+
probability: number
|
|
71
|
+
band: JevBand
|
|
72
|
+
agree: 'hit' | 'nearFloor' | 'gate-blocked' | 'wouldBlock' | 'n/a'
|
|
73
|
+
/** Which backend produced this verdict: 'primary' (default — the configured
|
|
74
|
+
* backend) or 'laya' (the local pace-maker). Design §3.4. */
|
|
75
|
+
backend?: string
|
|
76
|
+
/** True when this row's source DROVE the decision because the primary
|
|
77
|
+
* backend was unavailable (degraded mode). Comparison rows from the
|
|
78
|
+
* pace-maker are degraded=false; laya rows are excluded from ECE either
|
|
79
|
+
* way (uncalibrated probabilities). */
|
|
80
|
+
degraded?: boolean
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const COMPACT_LIMIT = 512 * 1024 // ~512KB cap, same as the ilog sidecars
|
|
84
|
+
|
|
85
|
+
export function decisionsFile(root: string): string {
|
|
86
|
+
return join(metaDir(root), 'decisions.jsonl')
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function liveFile(): string {
|
|
90
|
+
return decisionsFile(resolveBundleRoot())
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** digest(state, questionId, instructions): `h1:` + first 16 hex of
|
|
94
|
+
* sha256(state + NUL + questionId + NUL + instructions) — the key the
|
|
95
|
+
* repeat-decision-rate analysis joins on. */
|
|
96
|
+
export function digestFor(state: string, questionId: string, instructions: string): string {
|
|
97
|
+
const h = createHash('sha256')
|
|
98
|
+
h.update(state)
|
|
99
|
+
h.update('\u0000')
|
|
100
|
+
h.update(questionId)
|
|
101
|
+
h.update('\u0000')
|
|
102
|
+
h.update(instructions)
|
|
103
|
+
return `h1:${h.digest('hex').slice(0, 16)}`
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Serialized like the store's write queue: single-record appends are
|
|
107
|
+
// line-atomic anyway, but the compaction rewrite must not interleave with
|
|
108
|
+
// an append.
|
|
109
|
+
let queue: Promise<unknown> = Promise.resolve()
|
|
110
|
+
|
|
111
|
+
function enqueue<T>(op: () => Promise<T>): Promise<T> {
|
|
112
|
+
const run = queue.then(op, op)
|
|
113
|
+
queue = run.catch(() => undefined)
|
|
114
|
+
return run
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** All write failures die HERE — never upstream. */
|
|
118
|
+
async function appendJsonl(lines: readonly string[]): Promise<void> {
|
|
119
|
+
await enqueue(async () => {
|
|
120
|
+
try {
|
|
121
|
+
const file = liveFile()
|
|
122
|
+
await mkdir(dirname(file), { recursive: true })
|
|
123
|
+
await appendFile(file, lines.map((l) => `${l}\n`).join(''), 'utf8')
|
|
124
|
+
await compactIfNeeded(file)
|
|
125
|
+
} catch {
|
|
126
|
+
// fail-open: a broken stats sink must never break the feature (§6.1)
|
|
127
|
+
}
|
|
128
|
+
})
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// store.ts compactInjectionsIfNeeded pattern: ~512KB cap; keep the most
|
|
132
|
+
// recent quarter when exceeded (headroom for the next window).
|
|
133
|
+
async function compactIfNeeded(file: string): Promise<void> {
|
|
134
|
+
let raw: string
|
|
135
|
+
try {
|
|
136
|
+
raw = await readFile(file, 'utf8')
|
|
137
|
+
} catch {
|
|
138
|
+
return
|
|
139
|
+
}
|
|
140
|
+
if (raw.length <= COMPACT_LIMIT) return
|
|
141
|
+
const lines = raw.split('\n').filter((l) => l.trim() !== '')
|
|
142
|
+
const keep = lines.slice(-Math.max(1, Math.floor(lines.length / 4)))
|
|
143
|
+
const tmp = `${file}.tmp-${randomUUID()}`
|
|
144
|
+
try {
|
|
145
|
+
await writeFile(tmp, keep.map((l) => `${l}\n`).join(''), 'utf8')
|
|
146
|
+
await rename(tmp, file)
|
|
147
|
+
} catch {
|
|
148
|
+
// swallow — the next append retries the compaction
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Call-layer row. Field whitelist enforced here: anything extra on the
|
|
153
|
+
* caller's object is dropped, so no state/conclusion text can sneak in. */
|
|
154
|
+
export async function logCall(rec: JevCallRecord): Promise<void> {
|
|
155
|
+
await appendJsonl([
|
|
156
|
+
JSON.stringify({
|
|
157
|
+
at: rec.at,
|
|
158
|
+
lane: rec.lane,
|
|
159
|
+
backend: rec.backend,
|
|
160
|
+
model: rec.model,
|
|
161
|
+
questionCount: rec.questionCount,
|
|
162
|
+
stateChars: rec.stateChars,
|
|
163
|
+
latencyMs: rec.latencyMs,
|
|
164
|
+
usage:
|
|
165
|
+
rec.usage === null
|
|
166
|
+
? null
|
|
167
|
+
: { input_tokens: rec.usage.input_tokens, output_tokens: rec.usage.output_tokens },
|
|
168
|
+
outcome: rec.outcome,
|
|
169
|
+
fallback: rec.fallback,
|
|
170
|
+
}),
|
|
171
|
+
])
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Verdict-layer rows (batch expanded by the caller). */
|
|
175
|
+
export async function logVerdicts(recs: readonly JevVerdictRecord[]): Promise<void> {
|
|
176
|
+
if (recs.length === 0) return
|
|
177
|
+
await appendJsonl(
|
|
178
|
+
recs.map((rec) =>
|
|
179
|
+
JSON.stringify({
|
|
180
|
+
at: rec.at,
|
|
181
|
+
lane: rec.lane,
|
|
182
|
+
questionId: rec.questionId,
|
|
183
|
+
qtype: rec.qtype,
|
|
184
|
+
ref: rec.ref,
|
|
185
|
+
digest: rec.digest,
|
|
186
|
+
probability: rec.probability,
|
|
187
|
+
band: rec.band,
|
|
188
|
+
agree: rec.agree,
|
|
189
|
+
backend: rec.backend ?? 'primary',
|
|
190
|
+
degraded: rec.degraded === true,
|
|
191
|
+
}),
|
|
192
|
+
),
|
|
193
|
+
)
|
|
194
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* System One decision bands (design 2026-09-25 §5).
|
|
3
|
+
*
|
|
4
|
+
* The numbers are CALIBRATED — single-case A/B plus a 383-case threshold
|
|
5
|
+
* sweep (2026-09-25) — and bound to the batch wire protocol (protocol-drift
|
|
6
|
+
* evidence: the same case scores differently single vs batch). Changing the
|
|
7
|
+
* protocol requires a re-sweep; do not tune these in place.
|
|
8
|
+
*
|
|
9
|
+
* @module jev/thresholds
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Slow-lane rerank (候选问): adopt ≥ 0.60, strong-veto < 0.10. */
|
|
13
|
+
export const RERANK_ADOPT = 0.6
|
|
14
|
+
export const RERANK_FALLBACK = 0.1
|
|
15
|
+
|
|
16
|
+
/** Consolidation prefilter (pair 问「同一问题吗」): adopt ≥ 0.50, veto < 0.15. */
|
|
17
|
+
export const MERGE_PAIR_ADOPT = 0.5
|
|
18
|
+
export const MERGE_PAIR_FALLBACK = 0.15
|
|
19
|
+
|
|
20
|
+
export type JevBand = 'adopt' | 'record' | 'fallback'
|
|
21
|
+
export type JevBandKind = 'rerank' | 'mergePair'
|
|
22
|
+
|
|
23
|
+
/** Map a noul probability to its band: adopt → act on it, record → only log
|
|
24
|
+
* it, fallback → hard veto back to the lexical/legacy behavior. */
|
|
25
|
+
export function band(probability: number, kind: JevBandKind): JevBand {
|
|
26
|
+
const floor = kind === 'rerank' ? RERANK_FALLBACK : MERGE_PAIR_FALLBACK
|
|
27
|
+
const adopt = kind === 'rerank' ? RERANK_ADOPT : MERGE_PAIR_ADOPT
|
|
28
|
+
if (probability >= adopt) return 'adopt'
|
|
29
|
+
if (probability >= floor) return 'record'
|
|
30
|
+
return 'fallback'
|
|
31
|
+
}
|