@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/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
@@ -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. Calibration uses this to avoid observing the policy it
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 (!input.forceLexical) {
554
- try {
555
- const queryVec = (await clients.embed([input.query]))[0];
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", embedError);
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 } : {}),