@klhapp/skillmux 1.5.1 → 1.6.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 +17 -0
- package/docs/calibration.md +91 -4
- package/docs/cli.md +22 -1
- package/package.json +1 -1
- package/src/adapters.ts +410 -49
- package/src/calibrate.ts +526 -22
- package/src/cli.ts +75 -0
- package/src/router-core.ts +87 -13
package/src/cli.ts
CHANGED
|
@@ -475,6 +475,12 @@ async function handleCalibrateCommand(
|
|
|
475
475
|
let minRetrievalRecallAtK: number | undefined;
|
|
476
476
|
let minDeliveredShortlistRecallAtK: number | undefined;
|
|
477
477
|
let minAutoMatchCount: number | undefined;
|
|
478
|
+
let tuneAutoMatchPrecisionBuffer: number | undefined;
|
|
479
|
+
let tuneAutoMatchCountBuffer: number | undefined;
|
|
480
|
+
let tuneDeliveredShortlistRecallBuffer: number | undefined;
|
|
481
|
+
let concurrency: number | undefined;
|
|
482
|
+
let resumeRunId: string | undefined;
|
|
483
|
+
let timing = false;
|
|
478
484
|
const readNumber = (flag: string, raw: string | undefined): number => {
|
|
479
485
|
if (raw === undefined) throw new Error(`${flag} requires a value`);
|
|
480
486
|
const value = Number(raw);
|
|
@@ -497,6 +503,30 @@ async function handleCalibrateCommand(
|
|
|
497
503
|
if (!Number.isInteger(minAutoMatchCount) || minAutoMatchCount < 1) {
|
|
498
504
|
throw new Error("--min-auto-match-count must be a positive integer");
|
|
499
505
|
}
|
|
506
|
+
} else if (option === "--tune-auto-match-precision-buffer") {
|
|
507
|
+
tuneAutoMatchPrecisionBuffer = readNumber(option, args[++i]);
|
|
508
|
+
} else if (option === "--tune-auto-match-count-buffer") {
|
|
509
|
+
tuneAutoMatchCountBuffer = readNumber(option, args[++i]);
|
|
510
|
+
if (!Number.isInteger(tuneAutoMatchCountBuffer) || tuneAutoMatchCountBuffer < 0) {
|
|
511
|
+
throw new Error("--tune-auto-match-count-buffer must be a non-negative integer");
|
|
512
|
+
}
|
|
513
|
+
} else if (option === "--tune-delivered-shortlist-recall-buffer") {
|
|
514
|
+
tuneDeliveredShortlistRecallBuffer = readNumber(option, args[++i]);
|
|
515
|
+
} else if (option === "--concurrency") {
|
|
516
|
+
const raw = args[++i];
|
|
517
|
+
if (raw === undefined) throw new Error("--concurrency requires a value");
|
|
518
|
+
const val = Number(raw);
|
|
519
|
+
if (!Number.isInteger(val) || val < 1) {
|
|
520
|
+
throw new Error("--concurrency must be a positive integer");
|
|
521
|
+
}
|
|
522
|
+
concurrency = val;
|
|
523
|
+
} else if (option === "--resume") {
|
|
524
|
+
resumeRunId = args[++i];
|
|
525
|
+
if (!resumeRunId) throw new Error("--resume requires a run_id value");
|
|
526
|
+
} else if (option === "--timing") {
|
|
527
|
+
timing = true;
|
|
528
|
+
} else if (option === "--json") {
|
|
529
|
+
// Global flag accepted in the documented subcommand position
|
|
500
530
|
} else {
|
|
501
531
|
throw new Error(`unknown calibrate run option: ${option}`);
|
|
502
532
|
}
|
|
@@ -505,6 +535,8 @@ async function handleCalibrateCommand(
|
|
|
505
535
|
["--min-auto-match-precision", minAutoMatchPrecision],
|
|
506
536
|
["--min-retrieval-recall-at-k", minRetrievalRecallAtK],
|
|
507
537
|
["--min-delivered-shortlist-recall-at-k", minDeliveredShortlistRecallAtK],
|
|
538
|
+
["--tune-auto-match-precision-buffer", tuneAutoMatchPrecisionBuffer],
|
|
539
|
+
["--tune-delivered-shortlist-recall-buffer", tuneDeliveredShortlistRecallBuffer],
|
|
508
540
|
] as const) {
|
|
509
541
|
if (value !== undefined && (value < 0 || value > 1)) {
|
|
510
542
|
throw new Error(`${flag} must be between 0 and 1`);
|
|
@@ -516,6 +548,37 @@ async function handleCalibrateCommand(
|
|
|
516
548
|
minRetrievalRecallAtK,
|
|
517
549
|
minDeliveredShortlistRecallAtK,
|
|
518
550
|
minAutoMatchCount,
|
|
551
|
+
tuneAutoMatchPrecisionBuffer,
|
|
552
|
+
tuneAutoMatchCountBuffer,
|
|
553
|
+
tuneDeliveredShortlistRecallBuffer,
|
|
554
|
+
concurrency,
|
|
555
|
+
resumeRunId,
|
|
556
|
+
timing,
|
|
557
|
+
onTimingSummary: timing
|
|
558
|
+
? (summary) => {
|
|
559
|
+
// Write timing report to stderr only — stdout remains valid JSON under --json.
|
|
560
|
+
// Cumulative fields are total worker time across concurrent queries and may
|
|
561
|
+
// exceed wall_ms. They do not sum to wall time.
|
|
562
|
+
process.stderr.write(
|
|
563
|
+
[
|
|
564
|
+
"--- calibrate run timing ---",
|
|
565
|
+
`cases_total: ${summary.cases_total}`,
|
|
566
|
+
`cases_executed: ${summary.cases_executed} (retrieved in this invocation)`,
|
|
567
|
+
`cases_reused: ${summary.cases_reused} (loaded from prior interrupted run)`,
|
|
568
|
+
`wall_ms: ${summary.wall_ms.toFixed(1)}`,
|
|
569
|
+
`vault_sync_ms: ${summary.vault_sync_ms.toFixed(1)}`,
|
|
570
|
+
"cumulative worker time (concurrent totals; may exceed wall_ms):",
|
|
571
|
+
` cumulative_embedding_ms: ${summary.cumulative_embedding_ms.toFixed(1)}`,
|
|
572
|
+
` cumulative_lexical_ms: ${summary.cumulative_lexical_ms.toFixed(1)}`,
|
|
573
|
+
` cumulative_vector_ms: ${summary.cumulative_vector_ms.toFixed(1)}`,
|
|
574
|
+
` cumulative_reranker_ms: ${summary.cumulative_reranker_ms.toFixed(1)}`,
|
|
575
|
+
` cumulative_checkpoint_ms: ${summary.cumulative_checkpoint_ms.toFixed(1)}`,
|
|
576
|
+
`policy_evaluation_ms: ${summary.policy_evaluation_ms.toFixed(1)}`,
|
|
577
|
+
"",
|
|
578
|
+
].join("\n"),
|
|
579
|
+
);
|
|
580
|
+
}
|
|
581
|
+
: undefined,
|
|
519
582
|
});
|
|
520
583
|
emitSuccess({ isJson: ctx.isJson, target: ctx.target }, res, () => {
|
|
521
584
|
renderCalibrationTarget(ctx.target);
|
|
@@ -525,6 +588,7 @@ async function handleCalibrateCommand(
|
|
|
525
588
|
return;
|
|
526
589
|
}
|
|
527
590
|
|
|
591
|
+
|
|
528
592
|
if (sub === "list") {
|
|
529
593
|
const res = await adapter.calibrateList();
|
|
530
594
|
emitSuccess({ isJson: ctx.isJson, target: ctx.target }, res, () => {
|
|
@@ -656,6 +720,17 @@ Setup:
|
|
|
656
720
|
skillmux core <pin|unpin> <skill_id>... [--yes] [--dry-run] [--json]
|
|
657
721
|
skillmux skill which <skill_id>
|
|
658
722
|
|
|
723
|
+
Calibration:
|
|
724
|
+
skillmux calibrate run [--dataset <path>] [--concurrency <n>] [--resume <run_id>]
|
|
725
|
+
[--min-auto-match-precision <0..1>] [--min-auto-match-count <n>]
|
|
726
|
+
[--min-retrieval-recall-at-k <0..1>]
|
|
727
|
+
[--min-delivered-shortlist-recall-at-k <0..1>]
|
|
728
|
+
[--tune-auto-match-precision-buffer <0..1>]
|
|
729
|
+
[--tune-auto-match-count-buffer <n>]
|
|
730
|
+
[--tune-delivered-shortlist-recall-buffer <0..1>]
|
|
731
|
+
[--timing] [--json]
|
|
732
|
+
skillmux calibrate <list|show|apply|generate-dataset>
|
|
733
|
+
|
|
659
734
|
Init clients:
|
|
660
735
|
claude-code, codex, gemini-cli, opencode, github-copilot, windsurf,
|
|
661
736
|
antigravity, goose, hermes, skillmux-mcp
|
package/src/router-core.ts
CHANGED
|
@@ -530,18 +530,64 @@ export function classifyInferenceError(
|
|
|
530
530
|
return stage === "embedding" ? "embedding_unavailable" : "reranker_unavailable";
|
|
531
531
|
}
|
|
532
532
|
|
|
533
|
+
export interface StageTiming {
|
|
534
|
+
embedding_ms: number;
|
|
535
|
+
lexical_ms: number;
|
|
536
|
+
vector_ms: number;
|
|
537
|
+
reranker_ms: number;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
export interface RetrieveSnapshotOptions {
|
|
541
|
+
onTiming?: (timing: StageTiming) => void;
|
|
542
|
+
}
|
|
543
|
+
|
|
533
544
|
/**
|
|
534
545
|
* Retrieve the full fused candidate set and rerank it once without applying
|
|
535
|
-
* decision thresholds
|
|
536
|
-
* is trying to replace.
|
|
546
|
+
* decision thresholds, synchronizing the vault before reading the index.
|
|
537
547
|
*/
|
|
538
548
|
export async function retrieveAndRerank(
|
|
539
549
|
input: ResolveSkillInput,
|
|
540
550
|
): Promise<RetrievalResult> {
|
|
541
|
-
const { config, db } = await getEnv();
|
|
542
551
|
await syncVaultIfNeeded();
|
|
552
|
+
return retrieveAndRerankSnapshot(input);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* Retrieve candidates against an already-synchronized index snapshot without
|
|
557
|
+
* triggering syncVaultIfNeeded(). Calibration uses this to avoid observing the
|
|
558
|
+
* policy it is trying to replace while keeping one corpus for the whole run.
|
|
559
|
+
*/
|
|
560
|
+
export async function retrieveAndRerankSnapshot(
|
|
561
|
+
input: ResolveSkillInput,
|
|
562
|
+
options?: RetrieveSnapshotOptions,
|
|
563
|
+
): Promise<RetrievalResult> {
|
|
564
|
+
const { config, db } = await getEnv();
|
|
543
565
|
const clients = getClients();
|
|
566
|
+
const measureTiming = options?.onTiming !== undefined;
|
|
567
|
+
|
|
568
|
+
let embedding_ms = 0;
|
|
569
|
+
let lexical_ms = 0;
|
|
570
|
+
let vector_ms = 0;
|
|
571
|
+
let reranker_ms = 0;
|
|
572
|
+
|
|
573
|
+
const tEmbed0 = measureTiming ? performance.now() : 0;
|
|
574
|
+
const embedPromise =
|
|
575
|
+
!input.forceLexical && clients.embed
|
|
576
|
+
? clients.embed([input.query]).then(
|
|
577
|
+
(res) => {
|
|
578
|
+
if (measureTiming) embedding_ms = Math.max(0, performance.now() - tEmbed0);
|
|
579
|
+
return res;
|
|
580
|
+
},
|
|
581
|
+
(err) => {
|
|
582
|
+
if (measureTiming) embedding_ms = Math.max(0, performance.now() - tEmbed0);
|
|
583
|
+
return { error: err };
|
|
584
|
+
},
|
|
585
|
+
)
|
|
586
|
+
: null;
|
|
587
|
+
|
|
588
|
+
const tLex0 = measureTiming ? performance.now() : 0;
|
|
544
589
|
const lexical = ftsSearch(db, input.query, config.recall.k_lexical);
|
|
590
|
+
if (measureTiming) lexical_ms = Math.max(0, performance.now() - tLex0);
|
|
545
591
|
const lexicalRanks = new Map(lexical.map((row, index) => [row.skill_id, index + 1]));
|
|
546
592
|
|
|
547
593
|
let retrieval: RetrievalResult["retrieval"] = "lexical";
|
|
@@ -550,18 +596,12 @@ export async function retrieveAndRerank(
|
|
|
550
596
|
let rows = lexical;
|
|
551
597
|
let fusedRows: SkillRow[] | null = null;
|
|
552
598
|
|
|
553
|
-
if (
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
if (!queryVec) throw new Error("Embedding client returned no query vector.");
|
|
557
|
-
const nearest = vectorTopK(db, queryVec, config.recall.k_vector);
|
|
558
|
-
rows = reciprocalRankFusion(lexical, nearest);
|
|
559
|
-
fusedRows = rows;
|
|
560
|
-
retrieval = "hybrid";
|
|
561
|
-
} catch (embedError) {
|
|
599
|
+
if (embedPromise) {
|
|
600
|
+
const embedRes = await embedPromise;
|
|
601
|
+
if (embedRes && typeof embedRes === "object" && "error" in embedRes) {
|
|
562
602
|
retrieval = "lexical";
|
|
563
603
|
degraded_from = clients.rerank ? "reranked" : "hybrid";
|
|
564
|
-
degradation_reason = classifyInferenceError("embedding",
|
|
604
|
+
degradation_reason = classifyInferenceError("embedding", embedRes.error);
|
|
565
605
|
console.error(
|
|
566
606
|
JSON.stringify({
|
|
567
607
|
level: "warn",
|
|
@@ -570,6 +610,30 @@ export async function retrieveAndRerank(
|
|
|
570
610
|
reason: degradation_reason,
|
|
571
611
|
}),
|
|
572
612
|
);
|
|
613
|
+
} else {
|
|
614
|
+
const tVec0 = measureTiming ? performance.now() : 0;
|
|
615
|
+
try {
|
|
616
|
+
const queryVec = (embedRes as Float32Array[])[0];
|
|
617
|
+
if (!queryVec) throw new Error("Embedding client returned no query vector.");
|
|
618
|
+
const nearest = vectorTopK(db, queryVec, config.recall.k_vector);
|
|
619
|
+
if (measureTiming) vector_ms = Math.max(0, performance.now() - tVec0);
|
|
620
|
+
rows = reciprocalRankFusion(lexical, nearest);
|
|
621
|
+
fusedRows = rows;
|
|
622
|
+
retrieval = "hybrid";
|
|
623
|
+
} catch (embedError) {
|
|
624
|
+
if (measureTiming) vector_ms = Math.max(0, performance.now() - tVec0);
|
|
625
|
+
retrieval = "lexical";
|
|
626
|
+
degraded_from = clients.rerank ? "reranked" : "hybrid";
|
|
627
|
+
degradation_reason = classifyInferenceError("embedding", embedError);
|
|
628
|
+
console.error(
|
|
629
|
+
JSON.stringify({
|
|
630
|
+
level: "warn",
|
|
631
|
+
stage: "embedding",
|
|
632
|
+
degraded_from,
|
|
633
|
+
reason: degradation_reason,
|
|
634
|
+
}),
|
|
635
|
+
);
|
|
636
|
+
}
|
|
573
637
|
}
|
|
574
638
|
}
|
|
575
639
|
|
|
@@ -577,14 +641,17 @@ export async function retrieveAndRerank(
|
|
|
577
641
|
if (clients.rerank && retrieval === "hybrid" && rows.length > 0) {
|
|
578
642
|
const kRerank = config.recall.k_rerank ?? 10;
|
|
579
643
|
const rerankCandidates = rows.slice(0, kRerank);
|
|
644
|
+
const tRerank0 = measureTiming ? performance.now() : 0;
|
|
580
645
|
try {
|
|
581
646
|
scores = await clients.rerank(
|
|
582
647
|
input.query,
|
|
583
648
|
rerankCandidates.map((r) => ({ skill_id: r.skill_id, text: rerankText(r) })),
|
|
584
649
|
);
|
|
650
|
+
if (measureTiming) reranker_ms = Math.max(0, performance.now() - tRerank0);
|
|
585
651
|
retrieval = "reranked";
|
|
586
652
|
rows = rerankCandidates;
|
|
587
653
|
} catch (rerankError) {
|
|
654
|
+
if (measureTiming) reranker_ms = Math.max(0, performance.now() - tRerank0);
|
|
588
655
|
scores = null;
|
|
589
656
|
degraded_from = "reranked";
|
|
590
657
|
degradation_reason = classifyInferenceError("reranker", rerankError);
|
|
@@ -612,6 +679,13 @@ export async function retrieveAndRerank(
|
|
|
612
679
|
: new Map<string, number>();
|
|
613
680
|
const traceRows = fusedRows ?? rows;
|
|
614
681
|
|
|
682
|
+
options?.onTiming?.({
|
|
683
|
+
embedding_ms,
|
|
684
|
+
lexical_ms,
|
|
685
|
+
vector_ms,
|
|
686
|
+
reranker_ms,
|
|
687
|
+
});
|
|
688
|
+
|
|
615
689
|
return {
|
|
616
690
|
retrieval,
|
|
617
691
|
...(degraded_from ? { degraded_from, degradation_reason } : {}),
|