@tangle-network/agent-eval 0.122.9 → 0.123.1

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.
@@ -74,8 +74,18 @@ function selfNormalizedImportanceWeighting(trajectories, opts = {}) {
74
74
  function doublyRobust(trajectories, opts = {}) {
75
75
  const cap = opts.weightCap ?? Infinity;
76
76
  const clip = opts.rewardClip ?? { low: 0, high: 1 };
77
- if (trajectories.length === 0) return zeroEstimate();
77
+ if (trajectories.length === 0) {
78
+ return {
79
+ ...zeroEstimate(),
80
+ contributionCounts: { dr: 0, ipsFallback: 0, legacyScalar: 0 }
81
+ };
82
+ }
78
83
  const contributions = [];
84
+ const contributionCounts = {
85
+ dr: 0,
86
+ ipsFallback: 0,
87
+ legacyScalar: 0
88
+ };
79
89
  let maxW = 0;
80
90
  let sumW = 0;
81
91
  let sumW2 = 0;
@@ -85,11 +95,32 @@ function doublyRobust(trajectories, opts = {}) {
85
95
  }
86
96
  const w = Math.min(cap, t.targetProb / t.behaviorProb);
87
97
  const r = clamp(t.reward, clip.low, clip.high);
88
- const q = typeof t.qHat === "number" && Number.isFinite(t.qHat) ? clamp(t.qHat, clip.low, clip.high) : null;
89
- if (q === null) {
90
- contributions.push(w * r);
98
+ const rawQHatChosen = t.qHatChosen;
99
+ const rawVHatTarget = t.vHatTarget;
100
+ const hasQHatChosen = rawQHatChosen !== null && rawQHatChosen !== void 0;
101
+ const hasVHatTarget = rawVHatTarget !== null && rawVHatTarget !== void 0;
102
+ if (hasQHatChosen !== hasVHatTarget) {
103
+ throw new ValidationError(
104
+ `doublyRobust: qHatChosen and vHatTarget must be supplied together (runId=${t.runId})`
105
+ );
106
+ }
107
+ if (hasQHatChosen && hasVHatTarget) {
108
+ if (!Number.isFinite(rawQHatChosen) || !Number.isFinite(rawVHatTarget)) {
109
+ throw new ValidationError(
110
+ `doublyRobust: qHatChosen and vHatTarget must be finite (runId=${t.runId})`
111
+ );
112
+ }
113
+ const qHatChosen = clamp(rawQHatChosen, clip.low, clip.high);
114
+ const vHatTarget = clamp(rawVHatTarget, clip.low, clip.high);
115
+ contributions.push(vHatTarget + w * (r - qHatChosen));
116
+ contributionCounts.dr += 1;
117
+ } else if (typeof t.qHat === "number" && Number.isFinite(t.qHat)) {
118
+ const qHat = clamp(t.qHat, clip.low, clip.high);
119
+ contributions.push(qHat + w * (r - qHat));
120
+ contributionCounts.legacyScalar += 1;
91
121
  } else {
92
- contributions.push(q + w * (r - q));
122
+ contributions.push(w * r);
123
+ contributionCounts.ipsFallback += 1;
93
124
  }
94
125
  if (w > maxW) maxW = w;
95
126
  sumW += w;
@@ -104,7 +135,8 @@ function doublyRobust(trajectories, opts = {}) {
104
135
  standardError: Math.sqrt(variance / n),
105
136
  effectiveSampleSize: effN,
106
137
  n,
107
- maxImportanceWeight: maxW
138
+ maxImportanceWeight: maxW,
139
+ contributionCounts
108
140
  };
109
141
  }
110
142
  function offPolicyEstimateAll(trajectories, opts = {}) {
@@ -128,4 +160,4 @@ export {
128
160
  doublyRobust,
129
161
  offPolicyEstimateAll
130
162
  };
131
- //# sourceMappingURL=chunk-DTJ6QUQB.js.map
163
+ //# sourceMappingURL=chunk-VGRCHJON.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/rl/off-policy.ts"],"sourcesContent":["/**\n * Off-policy evaluation primitives.\n *\n * Standard inverse-probability-weighted (IPS), self-normalized\n * importance-weighted (SNIPS), and doubly-robust (DR) estimators for the\n * value of a *target* policy given trajectories collected under a\n * *behavior* policy. This is the canonical RL eval task: \"we have last\n * week's runs, we changed the policy — how would the new one do without\n * re-running?\"\n *\n * The math here is textbook (Dudík, Langford, Li 2011 for DR; Swaminathan\n * & Joachims 2015 for SNIPS) but the *application* to LLM-agent\n * evaluation needs care:\n *\n * - The \"policy\" is the (prompt, tool config, model snapshot) triple.\n * Two policies have the same probability over an action *iff* their\n * LLM call would emit the same token with the same probability —\n * which is generally unknowable without the model log-probs.\n * - For LLM agents, propensity scores must be supplied by the caller\n * (logged in the trace, recovered from token log-probs, or estimated\n * via a learned propensity model). We do NOT estimate propensity here.\n * - Doubly-robust requires two outputs from a Q-function: its prediction\n * for the logged action and its expectation under the target policy.\n * Consumers compute these with a tabular estimate, regression fit, or\n * learned reward model before constructing the trajectories.\n *\n * Bias / variance tradeoffs:\n * - IPS: unbiased; high variance for small overlap, infinite variance\n * when target has support outside behavior.\n * - SNIPS: lower variance, slight bias; usually preferred in practice.\n * - DR: doubly-robust — unbiased if either propensity OR Q-function is\n * correct. Lowest practical variance when Q is decent. Use this.\n *\n * Caveat the panel will land: on the LLM-agent setting, propensity scores\n * recovered from token log-probs are noisy, the action space is enormous,\n * and overlap is often poor. These estimators are useful but not magic;\n * complement with `replayCampaign` (exact replay where the request hashes\n * match) for high-confidence answers and OPE for the gap.\n */\n\nimport { ValidationError } from '../errors'\n\nexport interface OffPolicyTrajectory {\n /** Stable id, for traceability through the dataset. */\n runId: string\n /** Reward observed under the behavior policy (the realized outcome). */\n reward: number\n /**\n * Behavior-policy probability of the action that was taken. For LLM\n * agents this is typically `exp(sum(token_log_probs))` over the chosen\n * trajectory. Must be in (0, 1].\n */\n behaviorProb: number\n /**\n * Target-policy probability of the same action. For replay-style\n * counterfactual evaluation this is what the *new* policy would have\n * assigned to the *old* trajectory. Must be in [0, 1].\n */\n targetProb: number\n /**\n * Model-based reward prediction for the action selected by the behavior\n * policy: `Q_hat(context, loggedAction)`. Supply this together with\n * `vHatTarget` for contextual-bandit doubly-robust estimation.\n */\n qHatChosen?: number | null\n /**\n * Expected model-based reward under the target policy:\n * `sum_action targetPolicy(action | context) * Q_hat(context, action)`.\n * Supply this together with `qHatChosen`. For an honest evaluation, both\n * values must come from a model cross-fitted or trained outside this row.\n */\n vHatTarget?: number | null\n /**\n * @deprecated Use `qHatChosen` and `vHatTarget` together. When the new pair\n * is absent, this scalar is used as both terms to preserve existing results.\n * When the new pair is present, this field is ignored.\n */\n qHat?: number | null\n}\n\nexport interface OffPolicyContributionCounts {\n /** Contributions using the contextual-bandit doubly-robust formula. */\n dr: number\n /** Contributions using exact IPS because no reward-model estimate was supplied. */\n ipsFallback: number\n /** Contributions using the deprecated single-scalar formula. */\n legacyScalar: number\n}\n\nexport interface OffPolicyEstimate {\n /** Estimated value of the target policy. */\n value: number\n /** Standard error of the estimate. */\n standardError: number\n /** Effective sample size (Kong 1992). Lower = more reliance on a few high-weight samples. */\n effectiveSampleSize: number\n /** Number of trajectories used. */\n n: number\n /**\n * Diagnostic: maximum importance weight observed. Large values (>>10x\n * mean) are a red flag — variance is dominated by a few outliers.\n */\n maxImportanceWeight: number\n /** Populated by `doublyRobust` to expose which formula each row used. */\n contributionCounts?: OffPolicyContributionCounts\n}\n\nexport interface OffPolicyOptions {\n /**\n * Cap importance weights at this value (Ionides 2008 truncated IS) to\n * trade unbiasedness for variance reduction. Default `Infinity` (no cap).\n * Set e.g. `10` for stable estimates when the policies are close.\n */\n weightCap?: number\n /** Reward clipping range. Default `[0, 1]`. */\n rewardClip?: { low: number; high: number }\n}\n\n/**\n * Inverse Probability Weighting (Horvitz-Thompson). Unbiased estimator\n * of E[reward under target policy]. Variance scales with the spread of\n * target/behavior ratios.\n */\nexport function inverseProbabilityWeighting(\n trajectories: OffPolicyTrajectory[],\n opts: OffPolicyOptions = {},\n): OffPolicyEstimate {\n const cap = opts.weightCap ?? Infinity\n const clip = opts.rewardClip ?? { low: 0, high: 1 }\n\n if (trajectories.length === 0) {\n return zeroEstimate()\n }\n\n const weights: number[] = []\n const weightedRewards: number[] = []\n let maxW = 0\n for (const t of trajectories) {\n if (t.behaviorProb <= 0) {\n throw new ValidationError(\n `inverseProbabilityWeighting: behaviorProb must be > 0 (runId=${t.runId})`,\n )\n }\n const w = Math.min(cap, t.targetProb / t.behaviorProb)\n const r = clamp(t.reward, clip.low, clip.high)\n weights.push(w)\n weightedRewards.push(w * r)\n if (w > maxW) maxW = w\n }\n const n = weights.length\n const value = weightedRewards.reduce((s, x) => s + x, 0) / n\n const variance = weightedRewards.reduce((s, x) => s + (x - value) ** 2, 0) / Math.max(1, n - 1)\n const sumW = weights.reduce((s, w) => s + w, 0)\n const sumW2 = weights.reduce((s, w) => s + w * w, 0)\n const effN = sumW === 0 ? 0 : (sumW * sumW) / sumW2\n\n return {\n value,\n standardError: Math.sqrt(variance / n),\n effectiveSampleSize: effN,\n n,\n maxImportanceWeight: maxW,\n }\n}\n\n/**\n * Self-Normalized Importance Sampling. Lower variance than vanilla IPS at\n * the cost of small bias (vanishing as N grows). The right default for\n * LLM-agent evaluation where overlap is often poor.\n */\nexport function selfNormalizedImportanceWeighting(\n trajectories: OffPolicyTrajectory[],\n opts: OffPolicyOptions = {},\n): OffPolicyEstimate {\n const cap = opts.weightCap ?? Infinity\n const clip = opts.rewardClip ?? { low: 0, high: 1 }\n if (trajectories.length === 0) return zeroEstimate()\n\n const weights: number[] = []\n const rewards: number[] = []\n let maxW = 0\n for (const t of trajectories) {\n if (t.behaviorProb <= 0) {\n throw new ValidationError(\n `selfNormalizedImportanceWeighting: behaviorProb must be > 0 (runId=${t.runId})`,\n )\n }\n const w = Math.min(cap, t.targetProb / t.behaviorProb)\n weights.push(w)\n rewards.push(clamp(t.reward, clip.low, clip.high))\n if (w > maxW) maxW = w\n }\n const sumW = weights.reduce((s, w) => s + w, 0)\n const sumWR = weights.reduce((s, w, i) => s + w * rewards[i]!, 0)\n const value = sumW === 0 ? 0 : sumWR / sumW\n const sumW2 = weights.reduce((s, w) => s + w * w, 0)\n const effN = sumW === 0 ? 0 : (sumW * sumW) / sumW2\n // Influence-function-based SE for SNIPS (Owen 2013, Ch. 9).\n const phi = weights.map((w, i) => w * (rewards[i]! - value))\n const variance = phi.reduce((s, x) => s + x * x, 0) / Math.max(1, sumW * sumW)\n return {\n value,\n standardError: Math.sqrt(variance),\n effectiveSampleSize: effN,\n n: trajectories.length,\n maxImportanceWeight: maxW,\n }\n}\n\n/**\n * Doubly-robust off-policy estimator (Dudík, Langford, Li 2011).\n *\n * V_DR = (1/N) * sum_i [ v_hat_target_i\n * + (target_prob_i / behavior_prob_i) * (r_i - q_hat_chosen_i) ]\n *\n * Unbiased if EITHER:\n * - the importance ratios are correct (IPS-style validity), OR\n * - the Q-hat function is correct (model-based validity).\n *\n * In practice both are imperfect, but the residual bias is the *product*\n * of both errors — much smaller than either alone. This is why DR is the\n * default in production OPE pipelines.\n *\n * `qHatChosen` and `vHatTarget` must be supplied together. Rows with neither\n * use the exact IPS contribution. Deprecated `qHat` rows preserve the scalar\n * formula, and a complete new pair takes precedence when both forms exist.\n * `contributionCounts` makes the mix explicit in the result.\n * Callers must cross-fit the Q-function or train it on independent rows;\n * fitting and evaluating Q on the same outcomes leaks the answer.\n */\nexport function doublyRobust(\n trajectories: OffPolicyTrajectory[],\n opts: OffPolicyOptions = {},\n): OffPolicyEstimate {\n const cap = opts.weightCap ?? Infinity\n const clip = opts.rewardClip ?? { low: 0, high: 1 }\n if (trajectories.length === 0) {\n return {\n ...zeroEstimate(),\n contributionCounts: { dr: 0, ipsFallback: 0, legacyScalar: 0 },\n }\n }\n\n const contributions: number[] = []\n const contributionCounts: OffPolicyContributionCounts = {\n dr: 0,\n ipsFallback: 0,\n legacyScalar: 0,\n }\n let maxW = 0\n let sumW = 0\n let sumW2 = 0\n for (const t of trajectories) {\n if (t.behaviorProb <= 0) {\n throw new ValidationError(`doublyRobust: behaviorProb must be > 0 (runId=${t.runId})`)\n }\n const w = Math.min(cap, t.targetProb / t.behaviorProb)\n const r = clamp(t.reward, clip.low, clip.high)\n const rawQHatChosen = t.qHatChosen\n const rawVHatTarget = t.vHatTarget\n const hasQHatChosen = rawQHatChosen !== null && rawQHatChosen !== undefined\n const hasVHatTarget = rawVHatTarget !== null && rawVHatTarget !== undefined\n if (hasQHatChosen !== hasVHatTarget) {\n throw new ValidationError(\n `doublyRobust: qHatChosen and vHatTarget must be supplied together (runId=${t.runId})`,\n )\n }\n\n if (hasQHatChosen && hasVHatTarget) {\n if (!Number.isFinite(rawQHatChosen) || !Number.isFinite(rawVHatTarget)) {\n throw new ValidationError(\n `doublyRobust: qHatChosen and vHatTarget must be finite (runId=${t.runId})`,\n )\n }\n const qHatChosen = clamp(rawQHatChosen, clip.low, clip.high)\n const vHatTarget = clamp(rawVHatTarget, clip.low, clip.high)\n contributions.push(vHatTarget + w * (r - qHatChosen))\n contributionCounts.dr += 1\n } else if (typeof t.qHat === 'number' && Number.isFinite(t.qHat)) {\n const qHat = clamp(t.qHat, clip.low, clip.high)\n contributions.push(qHat + w * (r - qHat))\n contributionCounts.legacyScalar += 1\n } else {\n contributions.push(w * r)\n contributionCounts.ipsFallback += 1\n }\n if (w > maxW) maxW = w\n sumW += w\n sumW2 += w * w\n }\n const n = contributions.length\n const value = contributions.reduce((s, x) => s + x, 0) / n\n const variance = contributions.reduce((s, x) => s + (x - value) ** 2, 0) / Math.max(1, n - 1)\n const effN = sumW === 0 ? 0 : (sumW * sumW) / sumW2\n return {\n value,\n standardError: Math.sqrt(variance / n),\n effectiveSampleSize: effN,\n n,\n maxImportanceWeight: maxW,\n contributionCounts,\n }\n}\n\n/**\n * Convenience: run all three estimators and return them side-by-side.\n * The recommended diagnostic — agreement across estimators is a much\n * stronger signal than any single one.\n */\nexport function offPolicyEstimateAll(\n trajectories: OffPolicyTrajectory[],\n opts: OffPolicyOptions = {},\n): { ips: OffPolicyEstimate; snips: OffPolicyEstimate; dr: OffPolicyEstimate } {\n return {\n ips: inverseProbabilityWeighting(trajectories, opts),\n snips: selfNormalizedImportanceWeighting(trajectories, opts),\n dr: doublyRobust(trajectories, opts),\n }\n}\n\n// ── Helpers ──────────────────────────────────────────────────────────────\n\nfunction zeroEstimate(): OffPolicyEstimate {\n return { value: 0, standardError: 0, effectiveSampleSize: 0, n: 0, maxImportanceWeight: 0 }\n}\n\nfunction clamp(x: number, lo: number, hi: number): number {\n if (!Number.isFinite(x)) return lo\n return Math.max(lo, Math.min(hi, x))\n}\n"],"mappings":"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","names":[]}
@@ -2386,6 +2386,15 @@ type RunImprovementLoopOptions<TScenario extends Scenario, TArtifact> = RunOptim
2386
2386
  /** Holdout scenarios kept OUT of the training optimization pool — used
2387
2387
  * ONLY to score baseline vs winner for the gate. */
2388
2388
  holdoutScenarios: TScenario[];
2389
+ /** Holdout policy. Default `'measured'`: baseline + winner are re-scored on
2390
+ * `holdoutScenarios` and the gate decides on that held-out comparison.
2391
+ * `'deferred'`: the improvement-set (search) campaigns run exactly as usual,
2392
+ * but ZERO holdout cells are dispatched, the gate is forced to `'hold'`, and
2393
+ * the result + provenance record carry `holdout: 'deferred'` with NO
2394
+ * held-out lift — for callers that measure the held-out comparison in a
2395
+ * separate later run instead of faking a static holdout scenario and
2396
+ * recording a meaningless lift. */
2397
+ holdout?: 'measured' | 'deferred';
2389
2398
  /** Promotion gate. Substrate strongly recommends `defaultProductionGate`
2390
2399
  * for production wiring (composes red-team / reward-hacking / canary /
2391
2400
  * heldout). */
@@ -2413,6 +2422,10 @@ interface RunImprovementLoopResult<TArtifact, TScenario extends Scenario> extend
2413
2422
  neutralizedOnHoldout?: CampaignResult<TArtifact, TScenario>;
2414
2423
  neutralizedSurface?: MutableSurface;
2415
2424
  gateResult: Awaited<ReturnType<Gate<TArtifact, TScenario>['decide']>>;
2425
+ /** Present iff the loop ran with `holdout: 'deferred'`. When set,
2426
+ * `baselineOnHoldout`/`winnerOnHoldout` are the shared EMPTY campaign (zero
2427
+ * cells dispatched) and the gate verdict is the forced `'hold'`. */
2428
+ holdout?: 'deferred';
2416
2429
  /** Unified baseline→winner surface diff. Computed UNCONDITIONALLY (not only
2417
2430
  * when `autoOnPromote === 'pr'`) so the diff that the gate decided on is
2418
2431
  * always present on the result + in the emitted provenance record. Empty
@@ -3715,12 +3728,18 @@ interface LoopProvenanceRecord {
3715
3728
  detail: unknown;
3716
3729
  }>;
3717
3730
  };
3718
- /** baseline-on-holdout composite mean. */
3719
- baselineHoldoutComposite: number;
3720
- /** winner-on-holdout composite mean. */
3721
- winnerHoldoutComposite: number;
3722
- /** winnerHoldout - baselineHoldout — RECOMPUTABLE from this record. */
3723
- heldOutLift: number;
3731
+ /** Present iff the loop ran with `holdout: 'deferred'` the held-out
3732
+ * comparison was intentionally not measured in this run, so the holdout
3733
+ * composites and `heldOutLift` are ABSENT rather than recorded as a
3734
+ * meaningless 0. */
3735
+ holdout?: 'deferred';
3736
+ /** baseline-on-holdout composite mean. Absent when `holdout === 'deferred'`. */
3737
+ baselineHoldoutComposite?: number;
3738
+ /** winner-on-holdout composite mean. Absent when `holdout === 'deferred'`. */
3739
+ winnerHoldoutComposite?: number;
3740
+ /** winnerHoldout - baselineHoldout — RECOMPUTABLE from this record. Absent
3741
+ * when `holdout === 'deferred'` (no held-out measurement ran). */
3742
+ heldOutLift?: number;
3724
3743
  /** Backend provenance: stub-vs-real verdict + worker call count + models. */
3725
3744
  backend: LoopProvenanceBackend;
3726
3745
  totalCostUsd: number;
@@ -3767,8 +3786,22 @@ interface SelfImproveBudget {
3767
3786
  holdoutFraction?: number;
3768
3787
  /** Explicit held-out scenarios; overrides `holdoutFraction`. */
3769
3788
  holdoutScenarios?: Scenario[];
3789
+ /** Holdout policy. Default `'measured'`: split, re-score baseline vs winner
3790
+ * on the held-out set, gate on that comparison. `'deferred'`: run the
3791
+ * improvement-set campaigns + search promotion, dispatch ZERO holdout cells,
3792
+ * force the gate to `'hold'`, return `lift: undefined`, and record
3793
+ * `holdout: 'deferred'` in the provenance record — for callers that measure
3794
+ * the held-out comparison in a separate later run instead of faking a
3795
+ * static holdout scenario and recording a meaningless lift. Unless
3796
+ * `holdoutScenarios` reserves an explicit set, ALL scenarios train. */
3797
+ holdout?: 'measured' | 'deferred';
3770
3798
  /** Per-scenario replicates per cell — raises bootstrap-CI tightness. Default 1. */
3771
3799
  reps?: number;
3800
+ /** DEPTH dial forwarded to the proposer's `propose()` as
3801
+ * `ctx.maxImprovementShots` — max iterations an agentic candidate generator
3802
+ * may take per candidate (verify-in-session retries). Unset ⇒ the
3803
+ * proposer's own default. */
3804
+ maxImprovementShots?: number;
3772
3805
  /** @deprecated Must be 1 when supplied. The loop promotes only a candidate
3773
3806
  * that replaces its single global incumbent. */
3774
3807
  promoteTopK?: number;
@@ -3801,7 +3834,7 @@ type SelfImproveProgressEvent = {
3801
3834
  } | {
3802
3835
  kind: 'gate.decided';
3803
3836
  decision: string;
3804
- lift: number;
3837
+ lift?: number;
3805
3838
  } | {
3806
3839
  kind: 'power.estimated';
3807
3840
  n: number;
@@ -3835,6 +3868,18 @@ interface SelfImproveOptions<TScenario extends Scenario, TArtifact> {
3835
3868
  baselineSurface: MutableSurface;
3836
3869
  /** Budget + loop shape. All fields optional. */
3837
3870
  budget?: SelfImproveBudget;
3871
+ /**
3872
+ * Complete prior measurement of `baselineSurface` over the TRAIN split.
3873
+ * Forwarded to the loop body, which validates its surface hash, scenario
3874
+ * split, seed (42), reps, and coverage, then skips the baseline search
3875
+ * campaign entirely — no baseline dispatch, no resumability lookup. The
3876
+ * train split is `scenarios` minus the holdout split, so premeasure with
3877
+ * exactly that scenario set (explicit `budget.holdoutScenarios`, or
3878
+ * `budget.holdout: 'deferred'` with no reserved set, makes the train split
3879
+ * deterministic). Prior spend stays in the imported campaign aggregates and
3880
+ * is not re-added to this run's cost ledger.
3881
+ */
3882
+ premeasuredBaseline?: PremeasuredOptimizationBaseline<TArtifact, TScenario>;
3838
3883
  /** Custom surface proposer. Default is `gepaProposer` configured from `llm` +
3839
3884
  * `mutationPrimitives`. */
3840
3885
  proposer?: SurfaceProposer;
@@ -3934,12 +3979,16 @@ interface SelfImproveOptions<TScenario extends Scenario, TArtifact> {
3934
3979
  findings?: unknown[];
3935
3980
  }
3936
3981
  interface SelfImproveResult<TScenario extends Scenario, TArtifact> {
3937
- /** Composite mean across all scenarios, baseline run. */
3982
+ /** Composite mean across all scenarios, baseline run. When
3983
+ * `budget.holdout === 'deferred'` this is measured on the improvement
3984
+ * (search) split — no holdout campaign ran. */
3938
3985
  baseline: {
3939
3986
  compositeMean: number;
3940
3987
  perScenario: Record<string, number>;
3941
3988
  };
3942
- /** Composite mean on the held-out set, winner run. */
3989
+ /** Composite mean on the held-out set, winner run. When
3990
+ * `budget.holdout === 'deferred'` this is the winner's improvement-set
3991
+ * (search) measurement — no holdout campaign ran. */
3943
3992
  winner: {
3944
3993
  compositeMean: number;
3945
3994
  perScenario: Record<string, number>;
@@ -3953,8 +4002,10 @@ interface SelfImproveResult<TScenario extends Scenario, TArtifact> {
3953
4002
  rationale?: string;
3954
4003
  };
3955
4004
  /** `winner.compositeMean - baselineOnHoldout.compositeMean`. Positive
3956
- * means the gate observed improvement. */
3957
- lift: number;
4005
+ * means the gate observed improvement. Absent iff
4006
+ * `budget.holdout === 'deferred'` — no held-out measurement ran, so there
4007
+ * is no lift to report (never a fabricated 0). */
4008
+ lift?: number;
3958
4009
  /** The explicit baseline→winner unified diff. Always present (empty string
3959
4010
  * when winner == baseline). */
3960
4011
  diff: string;
@@ -36,7 +36,7 @@ import {
36
36
  runReferenceEquivalenceJudge,
37
37
  surfaceContentHash,
38
38
  surfaceHash
39
- } from "../chunk-4EDDIK77.js";
39
+ } from "../chunk-HZJF4IUO.js";
40
40
  import {
41
41
  campaignSplitDigest,
42
42
  createRunCostLedger,
@@ -125,6 +125,15 @@ function meanComposite(byScenario) {
125
125
  perScenario
126
126
  };
127
127
  }
128
+ function winnerSearchCampaign(result) {
129
+ for (let i = result.generations.length - 1; i >= 0; i--) {
130
+ const measured = result.generations[i]?.surfaces.find(
131
+ (s) => s.surfaceHash === result.winnerSurfaceHash
132
+ );
133
+ if (measured) return measured.campaign;
134
+ }
135
+ return result.baselineCampaign;
136
+ }
128
137
  async function selfImprove(opts) {
129
138
  const startedAt = Date.now();
130
139
  const requestedRunDir = opts.runDir ?? `mem://selfImprove-${startedAt}`;
@@ -147,18 +156,20 @@ async function runSelfImprove(opts, costLedger, startedAt, runDir, storage) {
147
156
  const populationSize = budget.populationSize ?? 2;
148
157
  const maxConcurrency = budget.maxConcurrency ?? 2;
149
158
  const holdoutFraction = budget.holdoutFraction ?? 0.25;
159
+ const holdoutMode = budget.holdout ?? "measured";
160
+ const holdoutDeferred = holdoutMode === "deferred";
150
161
  const expectUsage = opts.expectUsage ?? "assert";
151
162
  const explicitHoldout = budget.holdoutScenarios;
152
163
  const { train, holdout } = explicitHoldout ? {
153
164
  train: opts.scenarios.filter((s) => !explicitHoldout.some((h) => h.id === s.id)),
154
165
  holdout: explicitHoldout
155
- } : splitTrainHoldout(opts.scenarios, holdoutFraction);
166
+ } : holdoutDeferred ? { train: opts.scenarios, holdout: [] } : splitTrainHoldout(opts.scenarios, holdoutFraction);
156
167
  if (train.length === 0) {
157
168
  throw new Error(
158
169
  "selfImprove: train split is empty. Reduce holdoutFraction or pass more scenarios."
159
170
  );
160
171
  }
161
- if (holdout.length === 0) {
172
+ if (holdout.length === 0 && !holdoutDeferred) {
162
173
  throw new Error("selfImprove: holdout split is empty. Pass more scenarios.");
163
174
  }
164
175
  const proposer = opts.proposer ?? gepaProposer({
@@ -182,6 +193,7 @@ async function runSelfImprove(opts, costLedger, startedAt, runDir, storage) {
182
193
  const result = await runImprovementLoop({
183
194
  scenarios: train,
184
195
  baselineSurface: opts.baselineSurface,
196
+ premeasuredBaseline: opts.premeasuredBaseline,
185
197
  dispatchWithSurface: opts.agent,
186
198
  proposer,
187
199
  judges: [opts.judge],
@@ -189,7 +201,9 @@ async function runSelfImprove(opts, costLedger, startedAt, runDir, storage) {
189
201
  maxGenerations: generations,
190
202
  promoteTopK: budget.promoteTopK,
191
203
  reps: budget.reps,
204
+ maxImprovementShots: budget.maxImprovementShots,
192
205
  holdoutScenarios: holdout,
206
+ holdout: holdoutMode,
193
207
  gate,
194
208
  neutralize: opts.neutralize,
195
209
  autoOnPromote: opts.autoOnPromote ?? "none",
@@ -207,8 +221,11 @@ async function runSelfImprove(opts, costLedger, startedAt, runDir, storage) {
207
221
  analyzeGeneration: opts.analyzeGeneration,
208
222
  findings: opts.findings
209
223
  });
210
- const baseline = meanComposite(result.baselineOnHoldout.aggregates.byScenario);
211
- const winnerStats = meanComposite(result.winnerOnHoldout.aggregates.byScenario);
224
+ const winnerSearch = holdoutDeferred ? winnerSearchCampaign(result) : void 0;
225
+ const baseline = meanComposite(
226
+ (holdoutDeferred ? result.baselineCampaign : result.baselineOnHoldout).aggregates.byScenario
227
+ );
228
+ const winnerStats = meanComposite((winnerSearch ?? result.winnerOnHoldout).aggregates.byScenario);
212
229
  let power;
213
230
  const baselineHoldoutComposites = result.baselineOnHoldout.cells.filter((cell) => !cell.error).map((cell) => {
214
231
  const scores = Object.values(cell.judgeScores);
@@ -241,7 +258,10 @@ async function runSelfImprove(opts, costLedger, startedAt, runDir, storage) {
241
258
  opts.onProgress({
242
259
  kind: "gate.decided",
243
260
  decision: result.gateResult.decision,
244
- lift: winnerStats.compositeMean - baseline.compositeMean
261
+ // Deferred holdout has no held-out measurement: in that mode the summary
262
+ // stats are search-split numbers, and emitting their delta as `lift`
263
+ // would misreport a train-split delta as a held-out one. Omit instead.
264
+ ...holdoutDeferred ? {} : { lift: winnerStats.compositeMean - baseline.compositeMean }
245
265
  });
246
266
  }
247
267
  const cost = result.cost;
@@ -249,7 +269,12 @@ async function runSelfImprove(opts, costLedger, startedAt, runDir, storage) {
249
269
  const insight = await analyzeRuns({
250
270
  runs: [
251
271
  ...cellsToRunRecords(result.baselineCampaign.cells, "baseline", runDir, opts.baselineSurface),
252
- ...cellsToRunRecords(result.winnerOnHoldout.cells, "winner", runDir, result.winnerSurface)
272
+ ...cellsToRunRecords(
273
+ (winnerSearch ?? result.winnerOnHoldout).cells,
274
+ "winner",
275
+ runDir,
276
+ result.winnerSurface
277
+ )
253
278
  ],
254
279
  baselineCandidateId: "baseline",
255
280
  candidateCandidateId: "winner"
@@ -278,7 +303,7 @@ async function runSelfImprove(opts, costLedger, startedAt, runDir, storage) {
278
303
  ...result.winnerLabel ? { label: result.winnerLabel } : {},
279
304
  ...result.winnerRationale ? { rationale: result.winnerRationale } : {}
280
305
  },
281
- lift: winnerStats.compositeMean - baseline.compositeMean,
306
+ ...holdoutDeferred ? {} : { lift: winnerStats.compositeMean - baseline.compositeMean },
282
307
  diff: result.promotedDiff,
283
308
  provenance,
284
309
  gateDecision: result.gateResult.decision,