@dzhechkov/harness-core 0.7.11 → 0.7.12
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/.dz-manifest.json +93 -33
- package/README.md +26 -0
- package/dist/amendment-trace.d.ts +12 -1
- package/dist/amendment-trace.d.ts.map +1 -1
- package/dist/amendment-trace.js +22 -4
- package/dist/amendment-trace.js.map +1 -1
- package/dist/feature-adr-routing.d.ts +69 -12
- package/dist/feature-adr-routing.d.ts.map +1 -1
- package/dist/feature-adr-routing.js +117 -7
- package/dist/feature-adr-routing.js.map +1 -1
- package/dist/index.d.ts +7 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -3
- package/dist/index.js.map +1 -1
- package/dist/learning-backend.d.ts +39 -0
- package/dist/learning-backend.d.ts.map +1 -1
- package/dist/learning-backend.js +31 -11
- package/dist/learning-backend.js.map +1 -1
- package/dist/lesson-bandit.d.ts +116 -0
- package/dist/lesson-bandit.d.ts.map +1 -0
- package/dist/lesson-bandit.js +235 -0
- package/dist/lesson-bandit.js.map +1 -0
- package/dist/lesson-payoff.d.ts +260 -0
- package/dist/lesson-payoff.d.ts.map +1 -0
- package/dist/lesson-payoff.js +597 -0
- package/dist/lesson-payoff.js.map +1 -0
- package/dist/patterns.d.ts +21 -0
- package/dist/patterns.d.ts.map +1 -1
- package/dist/patterns.js +42 -3
- package/dist/patterns.js.map +1 -1
- package/dist/project-skills-root.d.ts +44 -0
- package/dist/project-skills-root.d.ts.map +1 -0
- package/dist/project-skills-root.js +62 -0
- package/dist/project-skills-root.js.map +1 -0
- package/dist/vector-tier.d.ts +30 -0
- package/dist/vector-tier.d.ts.map +1 -1
- package/dist/vector-tier.js +130 -15
- package/dist/vector-tier.js.map +1 -1
- package/package.json +4 -4
- package/sbom.json +182 -32
- package/src/amendment-trace.ts +34 -4
- package/src/feature-adr-routing.ts +146 -7
- package/src/index.ts +16 -2
- package/src/learning-backend.ts +62 -11
- package/src/lesson-bandit.ts +279 -0
- package/src/lesson-payoff.ts +728 -0
- package/src/patterns.ts +66 -5
- package/src/project-skills-root.ts +63 -0
- package/src/vector-tier.ts +182 -16
|
@@ -1321,9 +1321,36 @@ export function isSafeCodexId(id: string): boolean {
|
|
|
1321
1321
|
* unquoted, so a malformed `args.codexModel` could corrupt or extend the command the agent runs.
|
|
1322
1322
|
* Reject anything that is not a plain id, and single-quote it anyway.
|
|
1323
1323
|
*/
|
|
1324
|
-
|
|
1324
|
+
/**
|
|
1325
|
+
* Which binary bounds a dispatched run. `timeout(1)` is GNU coreutils and is NOT on macOS; brew's
|
|
1326
|
+
* coreutils installs it as `gtimeout`.
|
|
1327
|
+
*
|
|
1328
|
+
* An ALLOWLIST rather than a free string, because this value is interpolated into a shell command.
|
|
1329
|
+
*
|
|
1330
|
+
* MEASURED 2026-08-25 (field report, and reproduced here): with neither binary present the dispatch
|
|
1331
|
+
* exits 127 and cross-family QE — a NAMED safety property, that the model which wrote the code must
|
|
1332
|
+
* not review it — silently did not happen for a whole run.
|
|
1333
|
+
*
|
|
1334
|
+
* Deliberately NOT the portable `perl -e 'alarm N; exec @ARGV'` the report proposed: MEASURED on
|
|
1335
|
+
* this machine, that form exits **142** (SIGALRM kills the exec'd process) while GNU timeout exits
|
|
1336
|
+
* **124**, and `classifyCodexQeOutcome` keys `timeout` on `exit === 124` — a rule its own comment
|
|
1337
|
+
* says wins over every content rule. The suggested remedy would have silently reclassified every
|
|
1338
|
+
* timeout as a tool error. A remedy that breaks the classifier is worse than the defect.
|
|
1339
|
+
*/
|
|
1340
|
+
export const TIMEOUT_BINS: Readonly<Record<string, true>> = { timeout: true, gtimeout: true };
|
|
1341
|
+
|
|
1342
|
+
/** The requested timeout binary if it is one we allow, else the default. Never a free string. */
|
|
1343
|
+
export function timeoutBinOrDefault(bin: unknown): string {
|
|
1344
|
+
const b = typeof bin === 'string' ? bin : '';
|
|
1345
|
+
return TIMEOUT_BINS[b] === true ? b : 'timeout';
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
export function codexProbeCommand(id: string, timeoutBin?: string): string | null {
|
|
1325
1349
|
if (!isSafeCodexId(id)) return null;
|
|
1326
|
-
|
|
1350
|
+
// `< /dev/null` is not cosmetic: without it `codex exec` waits on stdin forever (measured
|
|
1351
|
+
// 2026-08-19, 45 minutes lost). It was present in the workflow mirror and MISSING here — a drift
|
|
1352
|
+
// invisible to the suite because this function is not in the lift test's function list.
|
|
1353
|
+
return timeoutBinOrDefault(timeoutBin) + " 60 codex exec -m '" + id + "' 'Reply with exactly: OK' < /dev/null";
|
|
1327
1354
|
}
|
|
1328
1355
|
|
|
1329
1356
|
export interface CodexProbeOutput {
|
|
@@ -1396,6 +1423,9 @@ export function parseCodexExecResult(text: string | null | undefined): CodexExec
|
|
|
1396
1423
|
|
|
1397
1424
|
/** Mode-A wall-clock bound. Run 3 measured 146 s; 600 s is ~4× headroom and still bounded. */
|
|
1398
1425
|
export const CODEX_REVIEW_TIMEOUT_SECONDS = 600;
|
|
1426
|
+
|
|
1427
|
+
/** The `codex exec` wall-clock bound. Mirrors the workflow's own constant (field report 27). */
|
|
1428
|
+
export const CODEX_EXEC_TIMEOUT_SECONDS = 280;
|
|
1399
1429
|
/** MEASURED (probe 0.3): `codex review` accepts and echoes `reasoning effort: high`. */
|
|
1400
1430
|
export const CODEX_REVIEW_DEFAULT_EFFORT = 'high';
|
|
1401
1431
|
/** The sentinel a wrapper returns when the command hit its `timeout` — distinct from CODEX_UNAVAILABLE. */
|
|
@@ -1427,6 +1457,22 @@ export interface CodexReviewCommandInput {
|
|
|
1427
1457
|
readonly ref?: string | null;
|
|
1428
1458
|
readonly modelId?: string | null;
|
|
1429
1459
|
readonly reasoning?: string | null;
|
|
1460
|
+
/**
|
|
1461
|
+
* Which binary bounds the run — `timeout` (default) or `gtimeout` on a mac with brew coreutils.
|
|
1462
|
+
* An INPUT rather than a platform sniff inside the builder, so the function stays pure and every
|
|
1463
|
+
* pinned command string in the tests stays byte-identical when it is omitted.
|
|
1464
|
+
*/
|
|
1465
|
+
readonly timeoutBin?: string | null;
|
|
1466
|
+
/**
|
|
1467
|
+
* The repo the review must run IN. Field report 27: `codex review` was dispatched with no working
|
|
1468
|
+
* directory at all, so it ran in the SESSION cwd — on a run against an external checkout it read a
|
|
1469
|
+
* different tree, resolved `--uncommitted` / `--base` against the wrong git repo, and still exited 0
|
|
1470
|
+
* with a `Grade:` line that the pipeline recorded as a verdict. `codex review` has NO `-C` flag
|
|
1471
|
+
* (MEASURED on codex-cli 0.149.1: `codex exec --help` carries `-C, --cd <DIR>`, `codex review --help`
|
|
1472
|
+
* does not), so the working directory can only be set by a `cd` prefix. Omitted ⇒ no prefix, which
|
|
1473
|
+
* keeps every previously pinned command string byte-identical.
|
|
1474
|
+
*/
|
|
1475
|
+
readonly repo?: string | null;
|
|
1430
1476
|
/**
|
|
1431
1477
|
* Accepted and DELIBERATELY IGNORED. MEASURED 2026-08-21: every scope flag refuses a positional
|
|
1432
1478
|
* prompt — `--commit`, `--base <BRANCH>` and `--uncommitted` each exit 2 with
|
|
@@ -1457,6 +1503,44 @@ export interface CodexReviewCommandResult {
|
|
|
1457
1503
|
* Default scope is `uncommitted` (probe 0.4b: it and `--base HEAD` reviewed the identical uncommitted
|
|
1458
1504
|
* diff, and `uncommitted` needs no ref, so it has no ref-injection surface at all).
|
|
1459
1505
|
*/
|
|
1506
|
+
/** Shell-quote one argument. A repo path may contain a space; it must never contain a command. */
|
|
1507
|
+
function codexSq(s: string): string {
|
|
1508
|
+
return "'" + String(s).replace(/'/g, "'\\''") + "'";
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
/**
|
|
1512
|
+
* The `cd <repo> && ` prefix, or `''` when no repo was named. Empty is not a silent default — it is
|
|
1513
|
+
* the pre-2026-08-25 behaviour, preserved so an omitted `repo` keeps every pinned string identical.
|
|
1514
|
+
*/
|
|
1515
|
+
function codexCd(repo: string): string {
|
|
1516
|
+
return repo === '' ? '' : 'cd ' + codexSq(repo) + ' && ';
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
/**
|
|
1520
|
+
* The `codex exec` dispatch, built as a pure string so the working directory is pinned by a test
|
|
1521
|
+
* rather than by whichever directory the dispatching agent happened to stand in (field report 27).
|
|
1522
|
+
* `-C` is real on codex-cli 0.149.1 and MEASURED: `-C <this repo>` answered in 4.6 s exit 0, while
|
|
1523
|
+
* `-C /tmp` exited 1 with "Not inside a trusted directory" — so the flag genuinely changes the tree,
|
|
1524
|
+
* and a non-git target is a LOUD failure rather than a quiet read of the wrong one.
|
|
1525
|
+
*/
|
|
1526
|
+
export function codexExecCommand(input: {
|
|
1527
|
+
readonly modelId?: string | null;
|
|
1528
|
+
readonly prompt?: string | null;
|
|
1529
|
+
readonly timeoutBin?: string | null;
|
|
1530
|
+
readonly timeoutSeconds?: number | null;
|
|
1531
|
+
readonly repo?: string | null;
|
|
1532
|
+
}): string | null {
|
|
1533
|
+
const o = input || {};
|
|
1534
|
+
const modelId = String(o.modelId === undefined || o.modelId === null ? '' : o.modelId);
|
|
1535
|
+
if (!isSafeCodexId(modelId)) return null;
|
|
1536
|
+
const raw = Number(o.timeoutSeconds);
|
|
1537
|
+
const seconds = raw === raw && raw !== Infinity && raw > 0 ? Math.floor(raw) : CODEX_EXEC_TIMEOUT_SECONDS;
|
|
1538
|
+
const repo = String(o.repo === undefined || o.repo === null ? '' : o.repo);
|
|
1539
|
+
const cd = repo === '' ? '' : ' -C ' + codexSq(repo);
|
|
1540
|
+
return timeoutBinOrDefault(o.timeoutBin) + ' ' + seconds + ' codex exec' + cd
|
|
1541
|
+
+ ' -m ' + codexSq(modelId) + ' ' + codexSq(String(o.prompt === undefined || o.prompt === null ? '' : o.prompt)) + ' < /dev/null';
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1460
1544
|
export function codexReviewCommand(input: CodexReviewCommandInput): CodexReviewCommandResult {
|
|
1461
1545
|
const o = input || ({} as CodexReviewCommandInput);
|
|
1462
1546
|
const scope = o.scope === undefined || o.scope === null || o.scope === '' ? 'uncommitted' : String(o.scope);
|
|
@@ -1477,7 +1561,8 @@ export function codexReviewCommand(input: CodexReviewCommandInput): CodexReviewC
|
|
|
1477
1561
|
}
|
|
1478
1562
|
const raw = Number(o.timeoutSeconds);
|
|
1479
1563
|
const seconds = raw === raw && raw !== Infinity && raw > 0 ? Math.floor(raw) : CODEX_REVIEW_TIMEOUT_SECONDS;
|
|
1480
|
-
|
|
1564
|
+
const repo = String(o.repo === undefined || o.repo === null ? '' : o.repo);
|
|
1565
|
+
let cmd = codexCd(repo) + timeoutBinOrDefault(o.timeoutBin) + ' ' + seconds + " codex review -c model='" + modelId + "' -c model_reasoning_effort='" + effort + "'";
|
|
1481
1566
|
if (scope === 'commit') cmd += " --commit '" + ref + "'";
|
|
1482
1567
|
else if (scope === 'base') cmd += " --base '" + ref + "'";
|
|
1483
1568
|
else cmd += ' --uncommitted';
|
|
@@ -1662,7 +1747,7 @@ export function gradeFromReviewFindings(findings: readonly CodexQeFinding[] | nu
|
|
|
1662
1747
|
* The LOCKED decline taxonomy. A `kind` outside this set is a bug, not a new case — which is why
|
|
1663
1748
|
* {@link codexQeDeclineReason} throws on one rather than rendering something plausible.
|
|
1664
1749
|
*/
|
|
1665
|
-
export const CODEX_QE_DECLINE_KINDS: readonly string[] = ['timeout', 'no-verdict', 'tool-error', 'unusable-output', 'unavailable', 'over-ceiling'];
|
|
1750
|
+
export const CODEX_QE_DECLINE_KINDS: readonly string[] = ['timeout', 'no-verdict', 'tool-error', 'unusable-output', 'unavailable', 'over-ceiling', 'wrong-tree'];
|
|
1666
1751
|
|
|
1667
1752
|
export interface ClassifyCodexQeInput {
|
|
1668
1753
|
readonly exit?: number | null;
|
|
@@ -1675,6 +1760,49 @@ export interface ClassifyCodexQeInput {
|
|
|
1675
1760
|
* that was never wrapped (a saved fixture, a file on disk), where content is all there is.
|
|
1676
1761
|
*/
|
|
1677
1762
|
readonly signalExpected?: boolean;
|
|
1763
|
+
/**
|
|
1764
|
+
* The files the dispatch DECLARED it would review. Used only by the wrong-tree rule below; absent
|
|
1765
|
+
* ⇒ that rule cannot fire and the classifier behaves exactly as it did before field report 27.
|
|
1766
|
+
*/
|
|
1767
|
+
readonly declaredFiles?: readonly string[] | null;
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1770
|
+
/**
|
|
1771
|
+
* Did the reviewer fail to FIND the very files it was told to read?
|
|
1772
|
+
*
|
|
1773
|
+
* A review dispatched into the wrong working directory does not error: the paths simply are not
|
|
1774
|
+
* there, the model says so in prose, and the command still exits 0 — often with a `Grade:` line,
|
|
1775
|
+
* which the pipeline then records as a verdict about code nobody read.
|
|
1776
|
+
*
|
|
1777
|
+
* The rule is deliberately narrow, because a review of a file-handling module may legitimately
|
|
1778
|
+
* DISCUSS "No such file or directory" — a mention is not a claim. So it fires only when one LINE
|
|
1779
|
+
* carries both the not-found phrase and one of the declared paths. With no declared paths there is
|
|
1780
|
+
* nothing to discriminate against and the rule stays silent rather than guessing.
|
|
1781
|
+
*/
|
|
1782
|
+
export function codexReviewMissedItsFiles(body: string | null | undefined, declaredFiles?: readonly string[] | null): boolean {
|
|
1783
|
+
const text = String(body === undefined || body === null ? '' : body);
|
|
1784
|
+
const files = Array.isArray(declaredFiles) ? declaredFiles.filter((f) => typeof f === 'string' && f !== '') : [];
|
|
1785
|
+
if (text === '' || files.length === 0) return false;
|
|
1786
|
+
// Only quotes, whitespace and a colon may sit between the path and the failure. Prose may not —
|
|
1787
|
+
// and that single restriction is what separates "the tool could not open this path" from "this
|
|
1788
|
+
// finding is ABOUT this path": a review finding always names its file, so anything looser marks
|
|
1789
|
+
// every file-handling review as wrong-tree. (Codex, gpt-5.6-sol, on the first version of this
|
|
1790
|
+
// function: the finding line "- [P2] Do not swallow file not found - src/io.ts:42" plus a stated
|
|
1791
|
+
// grade C was classified wrong-tree, discarding a valid cross-family verdict and falling back to
|
|
1792
|
+
// same-family QE — the guard against a false-clean review destroying a true one.)
|
|
1793
|
+
const GAP = '["\'\u2018\u2019\u201c\u201d\u0060(\\[\\s:,]{0,4}';
|
|
1794
|
+
const NOT_FOUND = 'no such file or directory|file not found|not found|does not exist|is not present|cannot be found';
|
|
1795
|
+
const VERB = '(?:cannot|can\'t|could not|couldn\'t|unable to|failed to|error(?: while)?)\\s+(?:open|read|find|access|stat|locate|load)';
|
|
1796
|
+
for (const f of files) {
|
|
1797
|
+
const q = f.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
1798
|
+
const shapes = [
|
|
1799
|
+
q + GAP + '(?:' + NOT_FOUND + ')', // src/io.ts: No such file or directory
|
|
1800
|
+
'(?:' + NOT_FOUND + ')' + GAP + q, // No such file or directory: src/io.ts
|
|
1801
|
+
VERB + GAP + q, // cannot open 'src/io.ts'
|
|
1802
|
+
];
|
|
1803
|
+
for (const shape of shapes) if (new RegExp(shape, 'i').test(text)) return true;
|
|
1804
|
+
}
|
|
1805
|
+
return false;
|
|
1678
1806
|
}
|
|
1679
1807
|
|
|
1680
1808
|
/**
|
|
@@ -1711,6 +1839,10 @@ export function classifyCodexQeOutcome(input: ClassifyCodexQeInput): { kind: str
|
|
|
1711
1839
|
} else if (exit !== 0) {
|
|
1712
1840
|
return { kind: 'tool-error' };
|
|
1713
1841
|
}
|
|
1842
|
+
// BEFORE the grade rule, and deliberately so: a wrong-tree review usually DOES state a grade, and
|
|
1843
|
+
// that grade is the most dangerous output this pipeline can produce — a clean letter about code
|
|
1844
|
+
// nobody read, recorded while crossFamilyQe.happened stays true.
|
|
1845
|
+
if (codexReviewMissedItsFiles(body, input ? input.declaredFiles : null)) return { kind: 'wrong-tree' };
|
|
1714
1846
|
if (grade !== null) return { kind: 'verdict' };
|
|
1715
1847
|
return { kind: 'no-verdict' };
|
|
1716
1848
|
}
|
|
@@ -1750,6 +1882,9 @@ export function codexQeDeclineReason(kind: string | null | undefined, detail?: C
|
|
|
1750
1882
|
const exit = d.exit === undefined || d.exit === null ? '?' : String(d.exit);
|
|
1751
1883
|
const chars = d.chars === undefined || d.chars === null ? '?' : String(d.chars);
|
|
1752
1884
|
const extra = d.detail === undefined || d.detail === null || String(d.detail) === '' ? 'no detail' : String(d.detail);
|
|
1885
|
+
if (canonical === 'wrong-tree') {
|
|
1886
|
+
return 'codex reported that the declared file(s) do not exist — the review ran in the WRONG working directory and its verdict is about a tree nobody asked for; ' + files + ' file(s) declared, exit ' + exit + ' (' + extra + ')';
|
|
1887
|
+
}
|
|
1753
1888
|
if (canonical === 'timeout') {
|
|
1754
1889
|
return 'codex review timed out after ' + elapsed + 's on scope ' + ref + ' (' + files + ' files) — NARROW the scope (this is reconnaissance cost, not thinking time)';
|
|
1755
1890
|
}
|
|
@@ -1763,7 +1898,11 @@ export function codexQeDeclineReason(kind: string | null | undefined, detail?: C
|
|
|
1763
1898
|
return 'codex exec unusable — ' + (d.reason === undefined || d.reason === null || String(d.reason) === '' ? 'codex exec returned no text' : String(d.reason));
|
|
1764
1899
|
}
|
|
1765
1900
|
if (canonical === 'unavailable') {
|
|
1766
|
-
|
|
1901
|
+
// The detail is the ONE field that carries the shell error, and this branch used to drop it
|
|
1902
|
+
// while `tool-error` right above rendered it — an asymmetry that made the field report's
|
|
1903
|
+
// "codex exec reported it could not run" unfixable blind. Same `extra`, same shape.
|
|
1904
|
+
const why = d.reason === undefined || d.reason === null || String(d.reason) === '' ? 'codex exec reported it could not run' : String(d.reason);
|
|
1905
|
+
return 'codex not used — ' + why + (extra === 'no detail' ? '' : ' (' + extra + ')');
|
|
1767
1906
|
}
|
|
1768
1907
|
if (canonical === 'over-ceiling') {
|
|
1769
1908
|
return 'prompt is ' + chars + ' chars / unscoped — refused before dispatch';
|
|
@@ -1804,12 +1943,12 @@ export interface CodexReviewResult {
|
|
|
1804
1943
|
* wrapper and calls {@link classifyCodexQeOutcome} directly with `signalExpected: true`, so a
|
|
1805
1944
|
* swallowed sentinel there is a `tool-error` and never a pass. A wiring test pins that.
|
|
1806
1945
|
*/
|
|
1807
|
-
export function parseCodexReviewResult(text: string | null | undefined): CodexReviewResult {
|
|
1946
|
+
export function parseCodexReviewResult(text: string | null | undefined, declaredFiles?: readonly string[] | null): CodexReviewResult {
|
|
1808
1947
|
const sig = parseCodexReviewSignal(text);
|
|
1809
1948
|
const findings = parseCodexReviewFindings(sig.body);
|
|
1810
1949
|
const stated = parseCodexGrade(sig.body);
|
|
1811
1950
|
const grade = stated !== null ? stated : gradeFromReviewFindings(findings);
|
|
1812
|
-
const outcome = classifyCodexQeOutcome({ exit: sig.exit, body: sig.body, grade: grade, findings: findings, signalExpected: sig.signalPresent });
|
|
1951
|
+
const outcome = classifyCodexQeOutcome({ exit: sig.exit, body: sig.body, grade: grade, findings: findings, signalExpected: sig.signalPresent, declaredFiles: declaredFiles === undefined ? null : declaredFiles });
|
|
1813
1952
|
const kind = outcome.kind === 'unusable-output' ? 'empty' : outcome.kind;
|
|
1814
1953
|
const ok = kind === 'verdict';
|
|
1815
1954
|
const reason = ok ? null : codexQeDeclineReason(kind, { elapsedSeconds: sig.elapsedSeconds, exit: sig.exit, chars: sig.body.length });
|
package/src/index.ts
CHANGED
|
@@ -81,6 +81,9 @@ export { stampCheckpointLine } from './checkpoint-stamp.js';
|
|
|
81
81
|
export { TELEMETRY_VOCAB_VERSION, TELEMETRY_FIELDS, PROVISIONAL_TELEMETRY_FIELDS, LOCAL_FIELD_ALIASES, telemetryFieldFor } from './telemetry-vocabulary.js';
|
|
82
82
|
export type { TelemetryField, FieldSource } from './telemetry-vocabulary.js';
|
|
83
83
|
export { planLedgerBackfill, LEDGER_FILL_SOURCE, AMBIGUOUS, resolveLedgerRunId } from './ledger-backfill.js';
|
|
84
|
+
// project-skills root resolution (field report doc-25b): the ONE builder behind both the Step-0
|
|
85
|
+
// probe and the PS_GUIDANCE paragraph, so the two can never look at different roots again.
|
|
86
|
+
export { projectSkillsOneRoot, projectSkillsProbeCommand } from './project-skills-root.js';
|
|
84
87
|
export type { LedgerBackfillPlan, LedgerBackfillRow, RunCostFacts } from './ledger-backfill.js';
|
|
85
88
|
export type { SweepResult, DriftedSkill, SyncResult, SyncCanonicalOptions } from './skill-drift.js';
|
|
86
89
|
export { benchmarkSkill, benchmarkSkills, compareSkills } from './benchmark.js';
|
|
@@ -119,12 +122,18 @@ export {
|
|
|
119
122
|
} from './qe-bridge.js';
|
|
120
123
|
export type { BridgeFamily, BridgeFailureReason, BridgeFinding, BridgeSignoff, BridgeParse, BridgeParseOk, BridgeParseFail, BridgeChannels, BridgeAudit, ClaudeResultExtraction, NamedExtract, BridgePromptInput } from './qe-bridge.js';
|
|
121
124
|
export type { PatternRecord, SessionRecord, LearningConfig, MemoryLearningConfig, LoadOptions, ConsolidateResult, ConsolidateOptions, SqliteBackendMode, RecallHit, SessionsSource, PruneNoiseResult, RemovePatternsResult, SnapshotStoreResult, ReinforcementState, ReinforcePatternResult, StoreStats, LessonDeltaReport, LessonDeltaRow, QuarantineState, PromoteResult, QuarantineExpiryCandidate } from './patterns.js';
|
|
122
|
-
export { DEFAULT_REINFORCE_THRESHOLD, NoopLearningBackend, NativeReinforcementBackend, resolveLearningBackend, isLearningSignalBackend } from './learning-backend.js';
|
|
123
|
-
export type { LearningSignalBackend, LearningSignalStats, LearningSample, SignalCandidate, EnhanceContext, TrainingResult, LearningBackendMode } from './learning-backend.js';
|
|
125
|
+
export { DEFAULT_REINFORCE_THRESHOLD, NoopLearningBackend, NativeReinforcementBackend, resolveLearningBackend, isLearningSignalBackend, applyLearningSignals, applyLearningSignalsWithDelta, applyLearningSignalsWithTerms } from './learning-backend.js';
|
|
126
|
+
export type { LearningSignalBackend, LearningSignalStats, LearningSample, SignalCandidate, EnhanceContext, TrainingResult, LearningBackendMode, RerankTerm } from './learning-backend.js';
|
|
127
|
+
// lesson-bandit-rerank (I-8): the ACL's public surface only. The vendored engine class is
|
|
128
|
+
// deliberately NOT exported — `selectArm`'s "pick one and commit" is authority this domain denies
|
|
129
|
+
// the ranker, and a foreign, invariant-blind API has no business on our safety-critical seam.
|
|
130
|
+
export { resolveBanditConfig, payoffTermsFor, recordReward, recordExposures, contextKeyFor, banditStats, renderBanditHealth, narrowBanditReport, loadBanditState, banditStatePath, banditStateDir, freshBanditEnvelope, makeRewardEvent, classifySignal, BANDIT_LOCK_NAME, BANDIT_STATE_SCHEMA } from './lesson-payoff.js';
|
|
131
|
+
export type { ResolvedBanditConfig, RewardEvent, ExposureEvent, PayoffTerm, PayoffTerms, BanditStateEnvelope, BanditLoadReason, BanditRecallReport, BanditHealth, BanditWriteOutcome } from './lesson-payoff.js';
|
|
124
132
|
export {
|
|
125
133
|
DEFAULT_VECTOR_TIMEOUT_MS,
|
|
126
134
|
DEFAULT_HARMONIZE_THRESHOLD,
|
|
127
135
|
REINFORCE_RRF_CAP,
|
|
136
|
+
BANDIT_RRF_CAP,
|
|
128
137
|
withVectorTimeout,
|
|
129
138
|
isVectorNoise,
|
|
130
139
|
patternVectorEntry,
|
|
@@ -634,6 +643,11 @@ export {
|
|
|
634
643
|
SCOPED_QE_PROMPT_MAX_CHARS,
|
|
635
644
|
isSafeCodexRef,
|
|
636
645
|
codexReviewCommand,
|
|
646
|
+
codexExecCommand,
|
|
647
|
+
codexReviewMissedItsFiles,
|
|
648
|
+
CODEX_EXEC_TIMEOUT_SECONDS,
|
|
649
|
+
timeoutBinOrDefault,
|
|
650
|
+
TIMEOUT_BINS,
|
|
637
651
|
codexQeSignalCommand,
|
|
638
652
|
scopedQePrompt,
|
|
639
653
|
parseCodexReviewSignal,
|
package/src/learning-backend.ts
CHANGED
|
@@ -30,6 +30,11 @@ export interface LearningSample {
|
|
|
30
30
|
readonly kind: LearningSampleKind;
|
|
31
31
|
readonly reward?: number;
|
|
32
32
|
readonly ts: string;
|
|
33
|
+
/** The recall domain this sample happened in, when the caller knows it. Threaded through to the
|
|
34
|
+
* bandit so a confirmation lands in the SAME context bucket the recall read from — without it a
|
|
35
|
+
* `dz recall --domain <x>` hit confirmed later writes to `general`, and `<x>` never sees it, which
|
|
36
|
+
* leaves domain-scoped re-ranking permanently inert (cross-family QE, gpt-5.6-sol). */
|
|
37
|
+
readonly domain?: string;
|
|
33
38
|
}
|
|
34
39
|
|
|
35
40
|
export interface TrainingResult {
|
|
@@ -122,6 +127,7 @@ export class NativeReinforcementBackend implements LearningSignalBackend {
|
|
|
122
127
|
ts: sample.ts,
|
|
123
128
|
...(sample.reward !== undefined ? { reward: sample.reward } : {}),
|
|
124
129
|
...(sample.kind === 'recall-hit' ? { exposure: true } : {}),
|
|
130
|
+
...(sample.domain !== undefined ? { domain: sample.domain } : {}),
|
|
125
131
|
});
|
|
126
132
|
if (r.ok) flushed += 1;
|
|
127
133
|
else {
|
|
@@ -192,19 +198,70 @@ export function resolveLearningBackend(projectRoot: string, config: MemoryLearni
|
|
|
192
198
|
}
|
|
193
199
|
}
|
|
194
200
|
|
|
195
|
-
|
|
201
|
+
/**
|
|
202
|
+
* One bounded re-rank term in the pipeline {@link applyLearningSignalsWithTerms} applies
|
|
203
|
+
* (feature lesson-bandit-rerank, architecture §6).
|
|
204
|
+
*
|
|
205
|
+
* WHY THIS EXISTS. `memory.learning.deltaRerank` is not a re-rank hook: it is a boolean that
|
|
206
|
+
* switches on ONE computation and selects between exactly two call sites. Reusing it for a second
|
|
207
|
+
* payoff signal would force one of two bad shapes — two independent features behind one switch, or
|
|
208
|
+
* a third `applyLearningSignalsWith…` function (and a fourth after that). A term LIST is the shape
|
|
209
|
+
* that stops growing.
|
|
210
|
+
*/
|
|
211
|
+
export interface RerankTerm {
|
|
212
|
+
/** `'delta'` | `'bandit'` — travels into the observability record. */
|
|
213
|
+
readonly id: string;
|
|
214
|
+
/** Raw per-candidate value, positionally aligned with `hits`/`candidates`. */
|
|
215
|
+
readonly byIndex: readonly number[];
|
|
216
|
+
/** Absolute bound on THIS term's contribution to a score. */
|
|
217
|
+
readonly cap: number;
|
|
218
|
+
/** Default `Math.tanh` (any magnitude → [-1,1]). A pre-bounded term passes identity. */
|
|
219
|
+
readonly squash?: (v: number) => number;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* The one scoring pipeline: `adjusted = hit.score + cap*signal[i] + Σ_t t.cap * squash(t.byIndex[i])`,
|
|
224
|
+
* stable-sorted with the original index as tie-break.
|
|
225
|
+
*
|
|
226
|
+
* With `terms: []` this is {@link applyLearningSignals} in behaviour; with one `'delta'` term it is
|
|
227
|
+
* {@link applyLearningSignalsWithDelta}. Both remain exported as thin wrappers, so every existing
|
|
228
|
+
* caller and test is untouched — the widening is additive at the API level too.
|
|
229
|
+
*
|
|
230
|
+
* A non-finite raw value contributes 0 and never NaN-poisons the sort (the guard the delta function
|
|
231
|
+
* already carried; generalising it would have been the easiest place to drop it).
|
|
232
|
+
*/
|
|
233
|
+
export function applyLearningSignalsWithTerms<H extends { readonly score: number }>(
|
|
196
234
|
hits: readonly H[],
|
|
197
235
|
backend: LearningSignalBackend,
|
|
198
236
|
candidates: readonly SignalCandidate[],
|
|
199
237
|
cap: number,
|
|
238
|
+
terms: readonly RerankTerm[] = [],
|
|
200
239
|
): H[] {
|
|
201
240
|
const signals = backend.enhance(candidates, { kind: 'recall', cap });
|
|
202
241
|
return hits
|
|
203
|
-
.map((hit, i) =>
|
|
242
|
+
.map((hit, i) => {
|
|
243
|
+
let extra = 0;
|
|
244
|
+
for (const t of terms) {
|
|
245
|
+
const raw = t.byIndex[i];
|
|
246
|
+
if (typeof raw !== 'number' || !Number.isFinite(raw)) continue;
|
|
247
|
+
const squashed = (t.squash ?? Math.tanh)(raw);
|
|
248
|
+
if (Number.isFinite(squashed)) extra += t.cap * squashed;
|
|
249
|
+
}
|
|
250
|
+
return { hit, adjusted: hit.score + cap * (signals[i] ?? 0) + extra, i };
|
|
251
|
+
})
|
|
204
252
|
.sort((a, b) => b.adjusted - a.adjusted || a.i - b.i)
|
|
205
253
|
.map((x) => x.hit);
|
|
206
254
|
}
|
|
207
255
|
|
|
256
|
+
export function applyLearningSignals<H extends { readonly score: number }>(
|
|
257
|
+
hits: readonly H[],
|
|
258
|
+
backend: LearningSignalBackend,
|
|
259
|
+
candidates: readonly SignalCandidate[],
|
|
260
|
+
cap: number,
|
|
261
|
+
): H[] {
|
|
262
|
+
return applyLearningSignalsWithTerms(hits, backend, candidates, cap, []);
|
|
263
|
+
}
|
|
264
|
+
|
|
208
265
|
/**
|
|
209
266
|
* {@link applyLearningSignals} plus a bounded ± SAFLA-delta term (rUv-scout #2 Phase 3). `deltaByIndex[i]`
|
|
210
267
|
* is candidate `i`'s raw payoff SLOPE (0 when it has no slope signal); each is squashed through `tanh`
|
|
@@ -220,13 +277,7 @@ export function applyLearningSignalsWithDelta<H extends { readonly score: number
|
|
|
220
277
|
deltaByIndex: readonly number[],
|
|
221
278
|
deltaCap: number,
|
|
222
279
|
): H[] {
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
const d = deltaByIndex[i];
|
|
227
|
-
const deltaTerm = typeof d === 'number' && Number.isFinite(d) ? deltaCap * Math.tanh(d) : 0;
|
|
228
|
-
return { hit, adjusted: hit.score + cap * (signals[i] ?? 0) + deltaTerm, i };
|
|
229
|
-
})
|
|
230
|
-
.sort((a, b) => b.adjusted - a.adjusted || a.i - b.i)
|
|
231
|
-
.map((x) => x.hit);
|
|
280
|
+
return applyLearningSignalsWithTerms(hits, backend, candidates, cap, [
|
|
281
|
+
{ id: 'delta', byIndex: deltaByIndex, cap: deltaCap },
|
|
282
|
+
]);
|
|
232
283
|
}
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VENDORED — contextual Thompson-Sampling bandit (feature lesson-bandit-rerank, ADR-001 D-1).
|
|
3
|
+
*
|
|
4
|
+
* Upstream: agentdb@3.0.0-alpha.20 — dist/src/backends/rvf/SolverBandit.js
|
|
5
|
+
* Licence: MIT, Copyright (c) 2024-2025 ruv
|
|
6
|
+
* Vendored: 2026-08-26. 215 lines, ZERO imports (both re-verified at copy time:
|
|
7
|
+
* `wc -l` → 215, `grep -cE 'require|^import'` → 0).
|
|
8
|
+
* SHA-256: 0199299fb60ef67afa030182185894c950126038398032349fe3618d910f6d7d
|
|
9
|
+
* (of the upstream .js at copy time; pinned by lesson-bandit-vendor.test.ts)
|
|
10
|
+
* Reason: the path is NOT in agentdb's package.json "exports" map (verified 2026-08-26);
|
|
11
|
+
* a deep import of a private path in a 3.0.0-alpha prerelease can change silently,
|
|
12
|
+
* and a ranking feature that quietly stops ranking looks exactly like one that works.
|
|
13
|
+
*
|
|
14
|
+
* Do not edit logic. Re-vendor from upstream and re-diff instead.
|
|
15
|
+
*
|
|
16
|
+
* WHAT CHANGED relative to the upstream .js, and nothing else:
|
|
17
|
+
* · `class SolverBandit` → `class LessonBandit` (ADR-001 D-1: one name, no alias);
|
|
18
|
+
* · TYPES added (field declarations, method signatures, the three exported interfaces);
|
|
19
|
+
* · `armKeys[0]` → `armKeys[0]!` and `ctx.get(armKey)` → `!` — required by this package's
|
|
20
|
+
* `noUncheckedIndexedAccess`; the emitted arithmetic is unchanged.
|
|
21
|
+
* The Jöhnk / Marsaglia-Tsang samplers, the `a<=1 && b<=1 ⇒ Math.random()` short-circuit, the
|
|
22
|
+
* exploration bonus and the cost EMA are preserved verbatim — the MEASURED API behaviour (200
|
|
23
|
+
* pulls on a 0.85-vs-0.15 pair → 100/100 correct picks) is a property of exactly this arithmetic.
|
|
24
|
+
*
|
|
25
|
+
* THIS FILE MUST STAY IMPORT-FREE (NFR-2 / C-7 / INV-6) — asserted by a repo test.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** Beta posterior + bookkeeping for one `(ContextKey, ArmKey)` pair. */
|
|
29
|
+
export interface ArmStats {
|
|
30
|
+
alpha: number;
|
|
31
|
+
beta: number;
|
|
32
|
+
pulls: number;
|
|
33
|
+
totalReward: number;
|
|
34
|
+
costEma: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Engine hyperparameters. This feature passes the vendored defaults through untouched. */
|
|
38
|
+
export interface BanditConfig {
|
|
39
|
+
costWeight: number;
|
|
40
|
+
costDecay: number;
|
|
41
|
+
explorationBonus: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The engine's own JSON-safe state. `version` belongs to the vendored copy, never to our envelope. */
|
|
45
|
+
export interface SerializedBanditState {
|
|
46
|
+
version: number;
|
|
47
|
+
config: BanditConfig;
|
|
48
|
+
contexts: Record<string, Record<string, ArmStats>>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Aggregate counters over every context. */
|
|
52
|
+
export interface BanditAggregateStats {
|
|
53
|
+
contexts: number;
|
|
54
|
+
totalArms: number;
|
|
55
|
+
totalPulls: number;
|
|
56
|
+
totalReward: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Thompson Sampling bandit with contextual arms.
|
|
61
|
+
*
|
|
62
|
+
* Usage:
|
|
63
|
+
* const bandit = new LessonBandit();
|
|
64
|
+
* const arm = bandit.selectArm('code_review', ['skill-a', 'skill-b', 'skill-c']);
|
|
65
|
+
* // ... execute the selected arm ...
|
|
66
|
+
* bandit.recordReward('code_review', arm, 0.85);
|
|
67
|
+
*/
|
|
68
|
+
export class LessonBandit {
|
|
69
|
+
private contexts = new Map<string, Map<string, ArmStats>>();
|
|
70
|
+
private readonly config: BanditConfig;
|
|
71
|
+
|
|
72
|
+
constructor(config?: Partial<BanditConfig>) {
|
|
73
|
+
this.config = {
|
|
74
|
+
costWeight: config?.costWeight ?? 0.01,
|
|
75
|
+
costDecay: config?.costDecay ?? 0.1,
|
|
76
|
+
explorationBonus: config?.explorationBonus ?? 0.1,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Select the best arm for a given context using Thompson Sampling.
|
|
82
|
+
*
|
|
83
|
+
* For each candidate arm, samples from its Beta(alpha, beta) distribution
|
|
84
|
+
* and subtracts a cost penalty. Returns the arm with the highest score.
|
|
85
|
+
* Unknown arms get an exploration bonus.
|
|
86
|
+
*/
|
|
87
|
+
selectArm(contextKey: string, armKeys: readonly string[]): string {
|
|
88
|
+
if (armKeys.length === 0)
|
|
89
|
+
throw new Error('No arms provided');
|
|
90
|
+
if (armKeys.length === 1)
|
|
91
|
+
return armKeys[0]!;
|
|
92
|
+
const ctx = this.contexts.get(contextKey);
|
|
93
|
+
let bestArm = armKeys[0]!;
|
|
94
|
+
let bestScore = -Infinity;
|
|
95
|
+
for (const arm of armKeys) {
|
|
96
|
+
const stats = ctx?.get(arm);
|
|
97
|
+
let score: number;
|
|
98
|
+
if (!stats || stats.pulls === 0) {
|
|
99
|
+
// Unknown arm: sample from uniform + exploration bonus
|
|
100
|
+
score = Math.random() + this.config.explorationBonus;
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
// Thompson sample from Beta(alpha, beta)
|
|
104
|
+
const sample = this.sampleBeta(stats.alpha, stats.beta);
|
|
105
|
+
score = sample - stats.costEma * this.config.costWeight;
|
|
106
|
+
}
|
|
107
|
+
if (score > bestScore) {
|
|
108
|
+
bestScore = score;
|
|
109
|
+
bestArm = arm;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return bestArm;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Record the outcome of pulling an arm.
|
|
117
|
+
*
|
|
118
|
+
* @param contextKey - The context bucket (e.g., task type)
|
|
119
|
+
* @param armKey - The arm that was pulled (e.g., skill name)
|
|
120
|
+
* @param reward - Success signal in [0, 1]
|
|
121
|
+
* @param cost - Optional cost signal (latency, tokens, etc.)
|
|
122
|
+
*/
|
|
123
|
+
recordReward(contextKey: string, armKey: string, reward: number, cost?: number): void {
|
|
124
|
+
if (!this.contexts.has(contextKey)) {
|
|
125
|
+
this.contexts.set(contextKey, new Map());
|
|
126
|
+
}
|
|
127
|
+
const ctx = this.contexts.get(contextKey)!;
|
|
128
|
+
if (!ctx.has(armKey)) {
|
|
129
|
+
ctx.set(armKey, { alpha: 1, beta: 1, pulls: 0, totalReward: 0, costEma: 0 });
|
|
130
|
+
}
|
|
131
|
+
const arm = ctx.get(armKey)!;
|
|
132
|
+
// Update Beta distribution
|
|
133
|
+
const r = Math.max(0, Math.min(1, reward));
|
|
134
|
+
arm.alpha += r;
|
|
135
|
+
arm.beta += (1 - r);
|
|
136
|
+
arm.pulls++;
|
|
137
|
+
arm.totalReward += r;
|
|
138
|
+
// Update cost EMA
|
|
139
|
+
if (cost !== undefined) {
|
|
140
|
+
arm.costEma = arm.costEma * (1 - this.config.costDecay) + cost * this.config.costDecay;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Rerank a list of candidates using bandit scores.
|
|
146
|
+
* Returns indices sorted by Thompson-sampled score (best first).
|
|
147
|
+
*/
|
|
148
|
+
rerank(contextKey: string, armKeys: readonly string[]): string[] {
|
|
149
|
+
if (armKeys.length <= 1)
|
|
150
|
+
return [...armKeys];
|
|
151
|
+
const ctx = this.contexts.get(contextKey);
|
|
152
|
+
const scored = armKeys.map((arm) => {
|
|
153
|
+
const stats = ctx?.get(arm);
|
|
154
|
+
let score: number;
|
|
155
|
+
if (!stats || stats.pulls === 0) {
|
|
156
|
+
score = Math.random() + this.config.explorationBonus;
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
score = this.sampleBeta(stats.alpha, stats.beta) - stats.costEma * this.config.costWeight;
|
|
160
|
+
}
|
|
161
|
+
return { arm, score };
|
|
162
|
+
});
|
|
163
|
+
scored.sort((a, b) => b.score - a.score);
|
|
164
|
+
return scored.map((s) => s.arm);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Get arm stats for a specific context */
|
|
168
|
+
getArmStats(contextKey: string, armKey: string): ArmStats | null {
|
|
169
|
+
return this.contexts.get(contextKey)?.get(armKey) ?? null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Get aggregate statistics */
|
|
173
|
+
getStats(): BanditAggregateStats {
|
|
174
|
+
let totalArms = 0, totalPulls = 0, totalReward = 0;
|
|
175
|
+
for (const ctx of this.contexts.values()) {
|
|
176
|
+
totalArms += ctx.size;
|
|
177
|
+
for (const arm of ctx.values()) {
|
|
178
|
+
totalPulls += arm.pulls;
|
|
179
|
+
totalReward += arm.totalReward;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return { contexts: this.contexts.size, totalArms, totalPulls, totalReward };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Serialize to JSON-safe state */
|
|
186
|
+
serialize(): SerializedBanditState {
|
|
187
|
+
const contexts: Record<string, Record<string, ArmStats>> = {};
|
|
188
|
+
for (const [ctxKey, arms] of this.contexts) {
|
|
189
|
+
contexts[ctxKey] = {};
|
|
190
|
+
for (const [armKey, stats] of arms) {
|
|
191
|
+
contexts[ctxKey]![armKey] = { ...stats };
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return { version: 1, config: { ...this.config }, contexts };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Restore from serialized state.
|
|
199
|
+
*
|
|
200
|
+
* STATIC (C-2). A first draft that called it as an instance method threw `TypeError`; the
|
|
201
|
+
* signature here makes the static call the only one that compiles.
|
|
202
|
+
*/
|
|
203
|
+
static deserialize(state: SerializedBanditState): LessonBandit {
|
|
204
|
+
const bandit = new LessonBandit(state.config);
|
|
205
|
+
for (const [ctxKey, arms] of Object.entries(state.contexts)) {
|
|
206
|
+
const ctx = new Map<string, ArmStats>();
|
|
207
|
+
for (const [armKey, stats] of Object.entries(arms)) {
|
|
208
|
+
ctx.set(armKey, { ...stats });
|
|
209
|
+
}
|
|
210
|
+
bandit.contexts.set(ctxKey, ctx);
|
|
211
|
+
}
|
|
212
|
+
return bandit;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Reset all learned state */
|
|
216
|
+
reset(): void {
|
|
217
|
+
this.contexts.clear();
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ─── Private ───
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Sample from Beta(a, b) using the Jöhnk algorithm.
|
|
224
|
+
* Fast approximation for typical bandit parameters.
|
|
225
|
+
*/
|
|
226
|
+
private sampleBeta(a: number, b: number): number {
|
|
227
|
+
// For a=1, b=1 (uniform): just return Math.random()
|
|
228
|
+
if (a <= 1 && b <= 1)
|
|
229
|
+
return Math.random();
|
|
230
|
+
// Jöhnk's algorithm for general Beta
|
|
231
|
+
if (a < 1 && b < 1) {
|
|
232
|
+
for (let iter = 0; iter < 1000; iter++) {
|
|
233
|
+
const u = Math.random();
|
|
234
|
+
const v = Math.random();
|
|
235
|
+
const x = Math.pow(u, 1 / a);
|
|
236
|
+
const y = Math.pow(v, 1 / b);
|
|
237
|
+
if (x + y <= 1)
|
|
238
|
+
return x / (x + y);
|
|
239
|
+
}
|
|
240
|
+
return Math.random(); // fallback (extremely unlikely)
|
|
241
|
+
}
|
|
242
|
+
// For larger parameters, use Gamma ratio
|
|
243
|
+
const ga = this.sampleGamma(a);
|
|
244
|
+
const gb = this.sampleGamma(b);
|
|
245
|
+
return ga / (ga + gb);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Sample from Gamma(shape, 1) using Marsaglia & Tsang's method.
|
|
250
|
+
*/
|
|
251
|
+
private sampleGamma(shape: number): number {
|
|
252
|
+
if (shape < 1) {
|
|
253
|
+
return this.sampleGamma(shape + 1) * Math.pow(Math.random(), 1 / shape);
|
|
254
|
+
}
|
|
255
|
+
const d = shape - 1 / 3;
|
|
256
|
+
const c = 1 / Math.sqrt(9 * d);
|
|
257
|
+
for (let iter = 0; iter < 1000; iter++) {
|
|
258
|
+
let x: number, v: number;
|
|
259
|
+
do {
|
|
260
|
+
x = this.sampleNormal();
|
|
261
|
+
v = 1 + c * x;
|
|
262
|
+
} while (v <= 0);
|
|
263
|
+
v = v * v * v;
|
|
264
|
+
const u = Math.random();
|
|
265
|
+
if (u < 1 - 0.0331 * (x * x) * (x * x))
|
|
266
|
+
return d * v;
|
|
267
|
+
if (Math.log(u) < 0.5 * x * x + d * (1 - v + Math.log(v)))
|
|
268
|
+
return d * v;
|
|
269
|
+
}
|
|
270
|
+
return d; // fallback (extremely unlikely)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Box-Muller normal sample */
|
|
274
|
+
private sampleNormal(): number {
|
|
275
|
+
const u1 = Math.random();
|
|
276
|
+
const u2 = Math.random();
|
|
277
|
+
return Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
|
|
278
|
+
}
|
|
279
|
+
}
|