pwn 0.5.642 → 0.5.647

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.
@@ -118,10 +118,11 @@ full `Loop.run` under a persona overlay) that share a JSONL bus. See
118
118
  local.
119
119
  - `PWN::AI::Agent::Learning.export_finetune` + `Reward.export_dpo` turn every
120
120
  successful session and every preference pair into supervised / DPO
121
- datasets under `~/.pwn/finetune/` - `Curriculum.train_and_gate` then
122
- LoRA-tunes the local model on them and only promotes the new adapter if it
123
- beats the old one on the current `Mistakes.top` set. See
124
- [Reinforcement Learning](Reinforcement-Learning.md).
121
+ datasets under `~/.pwn/finetune/` `Curriculum.train_and_gate` then
122
+ LoRA-tunes the local model and promotes only under **gate v2** (resolved
123
+ margin + mean judge + frozen smoke set). Preference pairs use trajectory
124
+ geometry (P9/P14/P15): revised answers / winning traces, not `CORRECTION:` prose; `scrub_preferences` + export filter; practice lands `shape: :winning_trace`.
125
+ See [Reinforcement Learning](Reinforcement-Learning.md).
125
126
 
126
127
  ## RL feature flags (`PWN::Env[:ai][:agent]`)
127
128
 
@@ -94,6 +94,15 @@ module PWN
94
94
  []
95
95
  end
96
96
  cool = load_cooldown
97
+ # P17 — prefer budget-exhaustion fingerprints (agent_loop / critic)
98
+ # so nightly self-play attacks the #1 live skill gap first.
99
+ candidates = candidates.sort_by do |m|
100
+ t = m[:tool].to_s
101
+ e = m[:error].to_s.downcase
102
+ budget = t == 'agent_loop' || t == 'assistant_answer' ||
103
+ e.include?('budget exhausted') || e.include?('iteration budget')
104
+ [budget ? 0 : 1, -m[:count].to_i]
105
+ end
97
106
  targets = candidates.reject { |m| practice_skip?(mistake: m, cooldown: cool) }.first(limit)
98
107
  results = []
99
108
 
@@ -107,20 +116,68 @@ module PWN
107
116
  mean = runs.empty? ? 0.0 : (runs.sum { |r| r[:score].to_f } / runs.length)
108
117
  resolved = false
109
118
  # 2.4 — auto-resolve only with N≥2 holdout successes + store trace
119
+ # P23 — auto-resolve only with N≥2 holdouts at judge≥0.7 AND a
120
+ # real tool trace (not empty-final luck). Budget fingerprints
121
+ # additionally require mean holdout ≥0.7 and short-horizon tags.
110
122
  if solved.length >= 2 && defined?(Mistakes)
111
123
  best = solved.max_by { |r| r[:score] }
124
+ winning = best[:trace].to_s.strip
125
+ winning = best[:final].to_s.strip if winning.length < 20
126
+ budgetish = %w[agent_loop assistant_answer].include?(m[:tool].to_s) ||
127
+ m[:error].to_s.downcase.include?('budget')
128
+ # refuse resolve on budget targets when winning_trace is prose-only
129
+ trace_ok = winning.length >= 20 && (
130
+ !budgetish || winning.match?(/→|shell|pwn_eval|tool/i) || best[:final].to_s.length.between?(1, 800)
131
+ )
132
+ unless trace_ok
133
+ bump_cooldown!(cooldown: cool, signature: m[:signature], mean: mean) unless dry_run
134
+ results << {
135
+ signature: m[:signature], tool: m[:tool], prompts: prompts,
136
+ runs: runs.map { |r| { score: r[:score], verdict: r[:verdict] } },
137
+ resolved: false, mean_score: mean.round(3),
138
+ reason: 'holdouts_ok_but_trace_weak'
139
+ }
140
+ next
141
+ end
112
142
  fix = best[:final].to_s.lines.first(3).join.strip[0, 400]
113
143
  Mistakes.resolve(
114
144
  signature: m[:signature],
115
145
  fix: "auto-curriculum: #{fix}",
116
146
  structured: {
117
- strategy: 'curriculum_practice',
147
+ strategy: budgetish ? 'short_horizon_finish' : 'curriculum_practice',
118
148
  tool: m[:tool],
119
149
  holdout_tests: solved.map { |r| r[:prompt] || r[:request] }.compact.first(5),
120
- winning_trace: best[:trace].to_s[0, 2_000]
150
+ winning_trace: winning[0, 2_000]
121
151
  }
122
152
  )
123
- Reward.record_preference(prompt: prompts.first.to_s, rejected: m[:snippet].to_s, chosen: fix, source: :curriculum) if defined?(Reward)
153
+ # P14 trajectory-shaped curriculum pair (not first-3-lines fix prose).
154
+ # Mistakes.resolve also lands a mistakes_resolve pair via winning_trace;
155
+ # this :curriculum row keeps W1 source diversity honest.
156
+ if defined?(Reward)
157
+ rejected = m[:snippet].to_s
158
+ rejected = "FAILING: tool=#{m[:tool]} err=#{m[:error]}" if rejected.strip.empty?
159
+ chosen = if best[:trace].to_s.strip.length >= 20
160
+ parts = []
161
+ parts << "STRATEGY: curriculum_practice | #{m[:tool]}"
162
+ parts << "WINNING_TRACE:\n#{best[:trace].to_s[0, 3_000]}"
163
+ parts << "FINAL:\n#{best[:final].to_s[0, 800]}" unless best[:final].to_s.strip.empty?
164
+ parts.join("\n")
165
+ else
166
+ best[:final].to_s[0, 3_500]
167
+ end
168
+ Reward.record_preference(
169
+ prompt: (best[:prompt] || prompts.first).to_s,
170
+ rejected: rejected[0, 2_000],
171
+ chosen: chosen,
172
+ source: :curriculum,
173
+ shape: :winning_trace,
174
+ meta: {
175
+ signature: m[:signature],
176
+ score: best[:score],
177
+ holdouts: solved.length
178
+ }
179
+ )
180
+ end
124
181
  resolved = true
125
182
  cool.delete(m[:signature].to_s)
126
183
  elsif !dry_run
@@ -245,7 +302,12 @@ module PWN
245
302
  end
246
303
 
247
304
  mean = scored.empty? ? nil : (scored.sum { |r| r[:score].to_f } / scored.length).round(3)
248
- out = { scored: scored.length, mean: mean, since_hours: since_h, results: scored.first(10) }
305
+ # P10 keep R3 window warm so proxy_distrust can engage on local hosts
306
+ warm = (Reward.warm_sentinel(limit: 120) if commit && defined?(Reward) && Reward.respond_to?(:warm_sentinel))
307
+ out = {
308
+ scored: scored.length, mean: mean, since_hours: since_h,
309
+ results: scored.first(10), sentinel_warm: warm
310
+ }
249
311
  log(event: :offline_judge, data: out)
250
312
  out
251
313
  rescue StandardError => e
@@ -257,6 +319,14 @@ module PWN
257
319
  public_class_method def self.preference_balance(opts = {})
258
320
  return { total: 0 } unless defined?(Reward)
259
321
 
322
+ # P15 — prefer Reward.preference_balance (geometry-aware + optional scrub).
323
+ if Reward.respond_to?(:preference_balance)
324
+ return Reward.preference_balance(
325
+ limit: opts[:limit] || 10_000,
326
+ scrub: opts.key?(:scrub) ? opts[:scrub] : false
327
+ )
328
+ end
329
+
260
330
  rows = Reward.preferences(limit: opts[:limit] || 10_000)
261
331
  by = Hash.new(0)
262
332
  rows.each { |r| by[r[:source].to_s] += 1 }
@@ -292,12 +362,39 @@ module PWN
292
362
  end
293
363
  return nil if branch_b.to_s.strip.empty?
294
364
 
295
- sa = score_branch(request: request, branch: branch_a)
296
- sb = score_branch(request: request, branch: branch_b)
297
- winner, loser, tag = sb > sa ? [branch_b, branch_a, :b] : [branch_a, branch_b, :a]
298
- Reward.record_preference(prompt: "#{request} | failing: #{opts[:name]} → #{opts[:error]}", rejected: loser, chosen: winner, source: :counterfactual) if defined?(Reward)
299
- log(event: :counterfactual, data: { branch: tag, a: sa, b: sb, tool: opts[:name].to_s })
300
- { branch: tag, content: winner, score: [sa, sb].max, a: sa, b: sb }
365
+ sa_h = score_branch_detailed(request: request, branch: branch_a)
366
+ sb_h = score_branch_detailed(request: request, branch: branch_b)
367
+ sa = sa_h[:score]
368
+ sb = sb_h[:score]
369
+ if sb > sa
370
+ winner = branch_b
371
+ loser = branch_a
372
+ tag = :b
373
+ wmeta = sb_h
374
+ else
375
+ winner = branch_a
376
+ loser = branch_b
377
+ tag = :a
378
+ wmeta = sa_h
379
+ end
380
+ real_hit = sa_h[:mode] == :real_dispatch || sb_h[:mode] == :real_dispatch
381
+ shape = real_hit ? :real_dispatch : :imagined
382
+ if defined?(Reward)
383
+ Reward.record_preference(
384
+ prompt: "#{request} | failing: #{opts[:name]} → #{opts[:error]}",
385
+ rejected: loser.to_s[0, 2_000],
386
+ chosen: winner.to_s[0, 2_000],
387
+ source: :counterfactual,
388
+ shape: shape,
389
+ meta: {
390
+ a_score: sa, b_score: sb,
391
+ a_mode: sa_h[:mode], b_mode: sb_h[:mode],
392
+ winner_trace: wmeta[:trace].to_s[0, 500]
393
+ }
394
+ )
395
+ end
396
+ log(event: :counterfactual, data: { branch: tag, a: sa, b: sb, tool: opts[:name].to_s, shape: shape })
397
+ { branch: tag, content: winner, score: [sa, sb].max, a: sa, b: sb, shape: shape, a_mode: sa_h[:mode], b_mode: sb_h[:mode] }
301
398
  rescue StandardError => e
302
399
  warn "[pwn-ai/curriculum] counterfactual swallowed: #{e.class}: #{e.message}"
303
400
  nil
@@ -319,9 +416,14 @@ module PWN
319
416
  # self-correction becomes DPO signal.
320
417
 
321
418
  public_class_method def self.critic(opts = {})
322
- return { verdict: :pass, source: :disabled } unless enabled?(key: :critic)
419
+ return { verdict: :pass, source: :disabled } unless enabled?(key: :critic) || opts[:text_only]
323
420
  return { verdict: :pass, source: :recursion } if in_curriculum?
324
421
 
422
+ # P24 — text_only: single Reflect shot, no tool-armed persona swarm.
423
+ # Used when budget_exhaustion_hot? so critic cannot thrash the budget
424
+ # that auto_introspect is trying to protect.
425
+ return critic_text_only(request: opts[:request], final: opts[:final], session_id: opts[:session_id]) if opts[:text_only]
426
+
325
427
  ensure_persona(name: CRITIC_NAME, role: "You are pwn-ai's constitutional critic. Given a REQUEST and a candidate ANSWER, find ONE concrete, verifiable flaw (wrong fact, missing step, unsupported claim, broken command). You MAY call shell / extro_verify / pwn_eval to check. If none found reply exactly: PASS. Otherwise reply: FLAW: <one line>.")
326
428
  reply = with_curriculum_guard do
327
429
  ask_persona(name: CRITIC_NAME, request: "REQUEST:\n#{opts[:request].to_s[0, 800]}\n\nANSWER:\n#{opts[:final].to_s[0, 2_000]}")
@@ -332,13 +434,22 @@ module PWN
332
434
  else
333
435
  flaw = reply.to_s.sub(/\AFLAW:\s*/i, '').strip[0, 300]
334
436
  Mistakes.record(tool: 'assistant_answer', error: "critic: #{flaw}", args: opts[:final].to_s[0, 200], session_id: opts[:session_id], source: :model) if defined?(Mistakes)
335
- # P5critic flaws are free DPO signal (rejected=answer, chosen=correction)
437
+ # P9DPO pair geometry: rejected=bad final, chosen=REVISED full
438
+ # answer (not "CORRECTION: flaw" prose). Prefer persona rewrite.
336
439
  if defined?(Reward) && !flaw.to_s.empty?
440
+ revised = revise_after_flaw(
441
+ request: opts[:request],
442
+ final: opts[:final],
443
+ flaw: flaw,
444
+ session_id: opts[:session_id]
445
+ )
337
446
  Reward.record_preference(
338
447
  prompt: opts[:request].to_s[0, 1_000],
339
448
  rejected: opts[:final].to_s[0, 2_000],
340
- chosen: "CORRECTION: #{flaw}",
341
- source: :critic
449
+ chosen: revised,
450
+ source: :critic,
451
+ shape: :revised_answer,
452
+ meta: { flaw: flaw.to_s[0, 200] }
342
453
  )
343
454
  end
344
455
  log(event: :critic, data: { verdict: :flaw, flaw: flaw.to_s[0, 200] })
@@ -462,8 +573,15 @@ module PWN
462
573
 
463
574
  candidate = ollama_create(base: base, adapter: adapter, version: version)
464
575
  baseline = state[:tag] || base
465
- gate = ab_gate(baseline: baseline, candidate: candidate, evalset: evalset)
466
- promoted = gate[:candidate_resolved] > gate[:baseline_resolved]
576
+ # P11 gate v2: resolved delta + mean judge + frozen smoke set.
577
+ gate = ab_gate_v2(baseline: baseline, candidate: candidate, evalset: evalset)
578
+ # P19 — refuse promote when W1 diet is still prose/monoculture.
579
+ # Export-only is correct until scrubbed pairs show trajectory diversity.
580
+ diet = preference_diet_gate
581
+ gate = gate.merge(preference_diet: diet)
582
+ promoted = gate[:promote] == true && diet[:ok] == true
583
+ gate[:promote] = promoted
584
+ gate[:promote_blocked_by_diet] = true unless diet[:ok]
467
585
  if promoted
468
586
  state[:previous] = state[:tag]
469
587
  state[:tag] = candidate
@@ -472,7 +590,7 @@ module PWN
472
590
  state[:gate] = gate
473
591
  save_models(state: state)
474
592
  end
475
- result.merge(adapter: adapter, candidate: candidate, gate: gate, promoted: promoted)
593
+ result.merge(adapter: adapter, candidate: candidate, gate: gate, promoted: promoted, weight_loop: :closed)
476
594
  rescue StandardError => e
477
595
  { error: "#{e.class}: #{e.message}" }
478
596
  end
@@ -496,6 +614,70 @@ module PWN
496
614
  # privates
497
615
  # ----------------------------------------------------------------
498
616
 
617
+ # P24 — single-shot text critic (no tools, no persona Loop.run).
618
+ private_class_method def self.critic_text_only(opts = {})
619
+ req = opts[:request].to_s
620
+ final = opts[:final].to_s
621
+ return { verdict: :pass, source: :text_only_empty } if final.strip.empty?
622
+
623
+ reply = if reflect_available?
624
+ Reflect.on(
625
+ request: "You are a strict critic. Given REQUEST and ANSWER, reply PASS or FLAW: <one line>.\nREQUEST:\n#{req[0, 600]}\nANSWER:\n#{final[0, 1_200]}",
626
+ suppress_pii_warning: true
627
+ ).to_s
628
+ else
629
+ # heuristic fallback: empty / self-reported failure / budget stop
630
+ if final.match?(/\[pwn-ai\].*budget|iteration budget exhausted|i (was )?unable to|failed to\b/i)
631
+ 'FLAW: answer reports failure or budget exhaustion'
632
+ else
633
+ 'PASS'
634
+ end
635
+ end
636
+ if reply.to_s.strip.upcase.start_with?('PASS')
637
+ { verdict: :pass, source: :text_only, confidence: 0.55 }
638
+ else
639
+ flaw = reply.to_s.sub(/\AFLAW:\s*/i, '').strip[0, 300]
640
+ # Do NOT Mistakes.record assistant_answer thrash from text_only path —
641
+ # that was feeding the budget-exhaustion mistake pile.
642
+ { verdict: :flaw, flaw: flaw, source: :text_only, confidence: 0.55 }
643
+ end
644
+ rescue StandardError => e
645
+ { verdict: :pass, source: :text_only_error, error: e.message }
646
+ end
647
+
648
+ # P9 — turn a critic flaw into a DPO-chosen REVISED answer (trajectory
649
+ # shaped), not "CORRECTION: <flaw>" commentary. Best-effort: ask Reflect
650
+ # to rewrite; fall back to a structured scaffold that still contains the
651
+ # original answer + the concrete fix.
652
+ private_class_method def self.revise_after_flaw(opts = {})
653
+ req = opts[:request].to_s
654
+ final = opts[:final].to_s
655
+ flaw = opts[:flaw].to_s
656
+ revised = nil
657
+ if reflect_available?
658
+ prompt = <<~P
659
+ Revise the ANSWER so it no longer has this flaw. Return the FULL
660
+ corrected answer only (no preamble, no "CORRECTION:" prefix).
661
+ REQUEST: #{req[0, 600]}
662
+ FLAW: #{flaw[0, 300]}
663
+ ANSWER: #{final[0, 1_500]}
664
+ P
665
+ revised = Reflect.on(request: prompt, suppress_pii_warning: true).to_s.strip
666
+ end
667
+ if revised.to_s.strip.empty? || revised.strip == final.strip || revised.match?(/\A\s*CORRECTION:/i)
668
+ revised = <<~REV.strip
669
+ REVISED ANSWER (addresses: #{flaw[0, 180]}):
670
+ #{final[0, 1_200]}
671
+
672
+ Fix applied: #{flaw[0, 300]}
673
+ Do not repeat the flawed claim or step above; prefer verified evidence from tools.
674
+ REV
675
+ end
676
+ revised.to_s[0, 4_000]
677
+ rescue StandardError
678
+ "REVISED ANSWER (addresses: #{opts[:flaw].to_s[0, 180]}):\n#{opts[:final].to_s[0, 1_200]}"
679
+ end
680
+
499
681
  private_class_method def self.generate_reproducers(opts = {})
500
682
  m = opts[:mistake]
501
683
  count = (opts[:count] || 2).to_i
@@ -581,13 +763,34 @@ module PWN
581
763
  'Use pwn_eval to list PWN::AI::Agent constants',
582
764
  'Return Dir.pwd from pwn_eval'
583
765
  ]
584
- else
766
+ when 'agent_loop', 'assistant_answer'
767
+ # P17 — dominant live failure: iteration / critic budget exhaustion.
768
+ # Practise finishing under a tight tool budget, not shell shapes.
585
769
  [
586
- "Demonstrate a correct use of the #{tool} tool on this host",
587
- "Use #{tool} to answer a simple factual question about this system",
588
- "Show a minimal successful #{tool} call with valid arguments",
589
- "Recover from a bad #{tool} invocation without retrying the same args"
770
+ 'Answer in one shell call: print kernel release with uname -r',
771
+ 'In at most two tools, show cwd and ruby version then stop',
772
+ 'Give a final answer with no tools: what is 7 times 8?',
773
+ 'Finish under three iterations: list /tmp and report file count',
774
+ 'Do not explore — one pwn_eval of Dir.pwd and return the path',
775
+ 'Short plan then one command: show free disk with df -h /'
590
776
  ]
777
+ else
778
+ if err.include?('budget exhausted') || err.include?('iteration budget') ||
779
+ (err.include?('handler_error') && err.include?('budget'))
780
+ [
781
+ 'Answer in one shell call: print kernel release with uname -r',
782
+ 'In at most two tools, show cwd and ruby version then stop',
783
+ 'Give a final answer with no tools: what is 7 times 8?',
784
+ 'Finish under three iterations: list /tmp and report file count'
785
+ ]
786
+ else
787
+ [
788
+ "Demonstrate a correct use of the #{tool} tool on this host",
789
+ "Use #{tool} to answer a simple factual question about this system",
790
+ "Show a minimal successful #{tool} call with valid arguments",
791
+ "Recover from a bad #{tool} invocation without retrying the same args"
792
+ ]
793
+ end
591
794
  end
592
795
  Array.new(n) { |i| pool[i % pool.length] }
593
796
  end
@@ -654,8 +857,29 @@ module PWN
654
857
 
655
858
  private_class_method def self.self_play(opts = {})
656
859
  sid = PWN::Sessions.create(title: "curriculum #{opts[:tag]}")[:id]
657
- final = Loop.run(request: opts[:prompt], session_id: sid, enabled_toolsets: %w[terminal pwn memory learning])
658
- v = Reward.judge(request: opts[:prompt], final: final, session_id: sid, commit: false)
860
+ # P23 short-horizon graded tasks: cap iters so practice actually
861
+ # teaches finish-under-N instead of letting DEFAULT_MAX_ITERS mask it.
862
+ prompt = opts[:prompt].to_s
863
+ short = prompt.match?(/\b(one shell|at most two|no tools|three iterations|finish under|do not explore|short plan)\b/i)
864
+ prev_max = :__unset__
865
+ capped = false
866
+ if short && defined?(PWN::Env) && PWN::Env.is_a?(Hash) && PWN::Env[:ai].is_a?(Hash) && PWN::Env[:ai][:agent].is_a?(Hash) && !PWN::Env[:ai][:agent].frozen?
867
+ prev_max = PWN::Env[:ai][:agent].key?(:max_iters) ? PWN::Env[:ai][:agent][:max_iters] : :__unset__
868
+ PWN::Env[:ai][:agent][:max_iters] = 5
869
+ capped = true
870
+ end
871
+ begin
872
+ final = Loop.run(request: prompt, session_id: sid, enabled_toolsets: %w[terminal pwn memory learning])
873
+ ensure
874
+ if capped && defined?(PWN::Env) && PWN::Env[:ai].is_a?(Hash) && PWN::Env[:ai][:agent].is_a?(Hash) && !PWN::Env[:ai][:agent].frozen?
875
+ if prev_max == :__unset__
876
+ PWN::Env[:ai][:agent].delete(:max_iters)
877
+ else
878
+ PWN::Env[:ai][:agent][:max_iters] = prev_max
879
+ end
880
+ end
881
+ end
882
+ v = Reward.judge(request: prompt, final: final, session_id: sid, commit: false)
659
883
  # 2.4 — capture tool trace for structured_fix.winning_trace
660
884
  trace = begin
661
885
  if defined?(PWN::Sessions)
@@ -673,21 +897,25 @@ module PWN
673
897
  end
674
898
 
675
899
  private_class_method def self.score_branch(opts = {})
676
- # 4.2 — prefer one-step real Dispatch when branch looks like tool JSON;
677
- # otherwise label imagined Reflect scores explicitly (not "advantage").
900
+ score_branch_detailed(**opts)[:score]
901
+ end
902
+
903
+ # Returns {score:, mode: :real_dispatch|:imagined|:default, trace:}.
904
+ # Callers that need honest advantage estimation should inspect :mode.
905
+ private_class_method def self.score_branch_detailed(opts = {})
678
906
  branch = opts[:branch].to_s
679
907
  request = opts[:request].to_s
680
908
  real = try_real_dispatch_score(branch: branch)
681
- return real if real
909
+ return { score: real, mode: :real_dispatch, trace: branch.to_s[0, 500] } if real
682
910
 
683
- return 0.5 unless reflect_available?
911
+ return { score: 0.5, mode: :default, trace: nil } unless reflect_available?
684
912
 
685
913
  req = "Goal: #{request}\nProposed next action: #{branch}\nOn a scale 0.0-1.0, how likely is this to advance the goal on a Kali Linux host? Reply with ONLY the number."
686
914
  imagined = Reflect.on(request: req, suppress_pii_warning: true).to_s[/[01](?:\.\d+)?/].to_f.clamp(0.0, 1.0)
687
915
  # haircut imagined scores so they never outrank a real dispatch
688
- (imagined * 0.6).clamp(0.0, 0.6)
916
+ { score: (imagined * 0.6).clamp(0.0, 0.6), mode: :imagined, trace: nil }
689
917
  rescue StandardError
690
- 0.5
918
+ { score: 0.5, mode: :default, trace: nil }
691
919
  end
692
920
 
693
921
  # Best-effort: if branch names a registered tool + args, run ONE Dispatch
@@ -767,6 +995,114 @@ module PWN
767
995
  { baseline: opts[:baseline], candidate: opts[:candidate], baseline_resolved: baseline, candidate_resolved: candid, evalset_size: evalset.length }
768
996
  end
769
997
 
998
+ # P11 — promote only when candidate beats baseline on (a) resolved count
999
+ # with margin, (b) mean judge score, and (c) no smoke regression. Smoke
1000
+ # set is fixed natural tasks, not Mistakes.top, so eval-set memorisation
1001
+ # cannot self-promote.
1002
+ private_class_method def self.ab_gate_v2(opts = {})
1003
+ evalset = Array(opts[:evalset])
1004
+ baseline = replay_on_detailed(tag: opts[:baseline], evalset: evalset)
1005
+ candid = replay_on_detailed(tag: opts[:candidate], evalset: evalset)
1006
+ smoke = smoke_eval_set
1007
+ base_smoke = replay_on_detailed(tag: opts[:baseline], evalset: smoke)
1008
+ cand_smoke = replay_on_detailed(tag: opts[:candidate], evalset: smoke)
1009
+
1010
+ b_res = baseline[:resolved].to_i
1011
+ c_res = candid[:resolved].to_i
1012
+ n = [evalset.length, 1].max
1013
+ delta = c_res - b_res
1014
+ rel = delta.to_f / n
1015
+ resolved_win = delta >= 1 && (n < 10 || rel >= 0.05)
1016
+
1017
+ b_mean = baseline[:mean_score].to_f
1018
+ c_mean = candid[:mean_score].to_f
1019
+ mean_win = c_mean + 1e-9 >= b_mean
1020
+
1021
+ smoke_ok = cand_smoke[:resolved].to_i >= base_smoke[:resolved].to_i &&
1022
+ cand_smoke[:mean_score].to_f + 0.05 >= base_smoke[:mean_score].to_f
1023
+
1024
+ promote = resolved_win && mean_win && smoke_ok
1025
+ {
1026
+ baseline: opts[:baseline],
1027
+ candidate: opts[:candidate],
1028
+ baseline_resolved: b_res,
1029
+ candidate_resolved: c_res,
1030
+ baseline_mean: b_mean.round(3),
1031
+ candidate_mean: c_mean.round(3),
1032
+ delta_resolved: delta,
1033
+ relative_delta: rel.round(3),
1034
+ smoke: {
1035
+ baseline_resolved: base_smoke[:resolved],
1036
+ candidate_resolved: cand_smoke[:resolved],
1037
+ baseline_mean: base_smoke[:mean_score],
1038
+ candidate_mean: cand_smoke[:mean_score]
1039
+ },
1040
+ resolved_win: resolved_win,
1041
+ mean_win: mean_win,
1042
+ smoke_ok: smoke_ok,
1043
+ promote: promote,
1044
+ evalset_size: evalset.length,
1045
+ gate_version: 2
1046
+ }
1047
+ end
1048
+
1049
+ private_class_method def self.smoke_eval_set
1050
+ [
1051
+ { signature: 'smoke_uname', prompt: 'Print the kernel release with uname -r' },
1052
+ { signature: 'smoke_pwd', prompt: 'Show the current working directory' },
1053
+ { signature: 'smoke_ruby', prompt: 'Display the active ruby version' }
1054
+ ]
1055
+ end
1056
+
1057
+ # P19 — weight promote requires scrubbed trajectory diversity, not just
1058
+ # A/B eval win. export_ready remains the correct posture otherwise.
1059
+ private_class_method def self.preference_diet_gate(opts = {})
1060
+ return { ok: false, reason: 'no Reward' } unless defined?(Reward)
1061
+
1062
+ bal = if Reward.respond_to?(:preference_balance)
1063
+ Reward.preference_balance(limit: opts[:limit] || 10_000, scrub: true)
1064
+ else
1065
+ preference_balance(limit: opts[:limit] || 10_000)
1066
+ end
1067
+ total = bal[:kept].to_i
1068
+ total = bal[:total].to_i if total <= 0
1069
+ return { ok: false, reason: 'too_few_pairs', total: total, min: 12 } if total < 12
1070
+
1071
+ frac = bal[:fractions] || {}
1072
+ max_share = frac.values.map(&:to_f).max || 1.0
1073
+ traj_frac = bal[:trajectory_fraction].to_f
1074
+ ok = !bal[:monoculture] && max_share <= 0.45 && traj_frac >= 0.30
1075
+ {
1076
+ ok: ok,
1077
+ total: total,
1078
+ monoculture: bal[:monoculture],
1079
+ max_source_share: max_share.round(3),
1080
+ trajectory_fraction: traj_frac.round(3),
1081
+ by_source: bal[:by_source],
1082
+ reason: ok ? 'diet_ok' : 'need_trajectory_diversity_or_rebalance'
1083
+ }
1084
+ rescue StandardError => e
1085
+ { ok: false, reason: "#{e.class}: #{e.message}" }
1086
+ end
1087
+
1088
+ private_class_method def self.replay_on_detailed(opts = {})
1089
+ tag = opts[:tag].to_s
1090
+ return { resolved: 0, mean_score: 0.0, scores: [] } if tag.empty?
1091
+
1092
+ scores = []
1093
+ with_ollama_model(tag: tag) do
1094
+ Array(opts[:evalset]).each do |e|
1095
+ r = self_play(prompt: e[:prompt], tag: "gate:#{tag}")
1096
+ scores << r[:score].to_f
1097
+ end
1098
+ end
1099
+ resolved = scores.count { |s| s >= 0.7 }
1100
+ mean = scores.empty? ? 0.0 : (scores.sum / scores.length)
1101
+ { resolved: resolved, mean_score: mean.round(3), scores: scores }
1102
+ rescue StandardError
1103
+ { resolved: 0, mean_score: 0.0, scores: [] }
1104
+ end
1105
+
770
1106
  private_class_method def self.replay_on(opts = {})
771
1107
  tag = opts[:tag].to_s
772
1108
  return 0 if tag.empty?
@@ -1118,7 +1454,7 @@ module PWN
1118
1454
  puts <<~USAGE
1119
1455
  USAGE:
1120
1456
  # Tier 4 — self-play
1121
- PWN::AI::Agent::Curriculum.practice(limit: 3) # S1 mistake-driven auto-curriculum
1457
+ PWN::AI::Agent::Curriculum.practice(limit: 3) # S1 + P14 trajectory DPO pairs + P17 budget-first
1122
1458
  PWN::AI::Agent::Curriculum.offline_judge(since_hours: 24) # P3 offline ORM/PRM fill
1123
1459
  PWN::AI::Agent::Curriculum.preference_balance # P5 W1 diversity report
1124
1460
  PWN::AI::Agent::Curriculum.counterfactual(request:, name:, args:, error:, hint:) # S2 A/B → DPO pair
@@ -1127,7 +1463,7 @@ module PWN
1127
1463
  PWN::AI::Agent::Curriculum.hindsight(request:, final:, session_id:) # C3 HER soft-relabel
1128
1464
 
1129
1465
  # Tier 5 — close the weight loop
1130
- PWN::AI::Agent::Curriculum.train_and_gate(dry_run: true) # W2 export-ready; promote only with trainer+dry_run:false
1466
+ PWN::AI::Agent::Curriculum.train_and_gate(dry_run: true) # W2 export-ready; P11 gate v2 promote only with trainer+dry_run:false
1131
1467
  PWN::AI::Agent::Curriculum.calibrate(predicted: 0.8, actual: 1.0) # W3 Brier → Metrics[:calibration]
1132
1468
 
1133
1469
  Cron self-improvement (seeded by PWN::Cron.install_defaults):