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.
@@ -168,7 +168,10 @@ module PWN
168
168
  # C2 — strict success:true only (excludes HER success:'soft'). Also
169
169
  # down-weight any residual hindsight-tagged rows so partial failures
170
170
  # never launder into full-strength few-shot exemplars.
171
+ # P20 — strict success:true AND prefer high judge scores. Drop rows
172
+ # with explicit low ORM score so proxy-true / judge-low cannot be few-shot.
171
173
  pool = outcomes(limit: 500, success: true).reject { |r| r[:session_id].to_s.empty? }
174
+ pool = pool.reject { |r| r.key?(:score) && r[:score].to_f < 0.6 }
172
175
  scored = pool.map do |r|
173
176
  sim = tokens.count { |t| r[:task].to_s.downcase.include?(t) }.to_f / tokens.length
174
177
  age_d = (now - Time.parse(r[:timestamp].to_s)) / 86_400.0
@@ -202,26 +205,65 @@ module PWN
202
205
  # Modelfile` over the export - the only path to ACTUAL parity with a
203
206
  # frontier model, because it changes the weights not just the scaffold.
204
207
 
208
+ # P12 — SFT quality gate (as hard as DPO source-cap): drop HER/soft,
209
+ # low judge_score, auto-only noise without score, and PRM-compress
210
+ # trajectories so LoRA is not 5MB of "how we flailed".
211
+ SFT_MIN_SCORE = 0.6
212
+ SFT_MAX_TOOL_CHARS = 1_200
213
+
205
214
  public_class_method def self.export_finetune(opts = {})
206
215
  fmt = (opts[:format] || :sharegpt).to_sym
207
216
  min_tools = (opts[:min_tools] || 1).to_i
217
+ min_score = (opts[:min_score] || SFT_MIN_SCORE).to_f
218
+ compress = opts.key?(:compress) ? opts[:compress] : true
208
219
  FileUtils.mkdir_p(FINETUNE_DIR)
209
220
  out = opts[:out] || File.join(FINETUNE_DIR, "pwn-#{Time.now.utc.strftime('%Y%m%d')}.jsonl")
210
221
 
211
- # 4.1 — exclude HER soft-success from SFT gold (success: 'soft' or tags)
222
+ # 4.1 / P12 — exclude HER soft-success + low-score + untagged auto flail
212
223
  gold = outcomes(limit: 10_000, success: true).reject do |r|
213
- r[:success].to_s == 'soft' ||
214
- Array(r[:tags]).any? { |t| %w[hindsight her].include?(t.to_s) }
224
+ tags = Array(r[:tags]).map(&:to_s)
225
+ soft = r[:success].to_s == 'soft' || tags.intersect?(%w[hindsight her soft])
226
+ low = !r[:score].nil? && r[:score].to_f < min_score
227
+ # require a score when present in corpus era that has scores
228
+ soft || low
229
+ end
230
+ # prefer highest-score outcome per session
231
+ by_sid = {}
232
+ gold.each do |r|
233
+ sid = r[:session_id].to_s
234
+ next if sid.empty?
235
+
236
+ prev = by_sid[sid]
237
+ by_sid[sid] = r if prev.nil? || r[:score].to_f >= prev[:score].to_f
215
238
  end
216
- sids = gold.map { |r| r[:session_id] }.compact.uniq
239
+ sids = by_sid.keys
217
240
  rows = 0
241
+ dropped = { tools: 0, empty: 0, load: 0 }
218
242
  File.open(out, 'w') do |f|
219
243
  sids.each do |sid|
220
- t = PWN::Sessions.load(session_id: sid)
221
- next if t.count { |e| e[:role].to_s == 'tool' } < min_tools
244
+ t = begin
245
+ PWN::Sessions.load(session_id: sid)
246
+ rescue StandardError
247
+ dropped[:load] += 1
248
+ next
249
+ end
250
+ tool_n = t.count { |e| e[:role].to_s == 'tool' }
251
+ if tool_n < min_tools
252
+ dropped[:tools] += 1
253
+ next
254
+ end
255
+
256
+ conv = if compress
257
+ compress_finetune_trace(transcript: t, max_tool_chars: SFT_MAX_TOOL_CHARS)
258
+ else
259
+ t.map { |e| { role: e[:role].to_s, content: e[:content].to_s } }
260
+ .reject { |e| e[:role] == 'system' && e[:content].start_with?('Session started') }
261
+ end
262
+ if conv.nil? || conv.empty? || conv.none? { |m| m[:role].to_s == 'assistant' }
263
+ dropped[:empty] += 1
264
+ next
265
+ end
222
266
 
223
- conv = t.map { |e| { role: e[:role].to_s, content: e[:content].to_s } }
224
- .reject { |e| e[:role] == 'system' && e[:content].start_with?('Session started') }
225
267
  line = case fmt
226
268
  when :openai_jsonl then { messages: conv }
227
269
  else { conversations: conv.map { |m| { from: sharegpt_role(role: m[:role]), value: m[:content] } } }
@@ -230,7 +272,11 @@ module PWN
230
272
  rows += 1
231
273
  end
232
274
  end
233
- { path: out, format: fmt, sessions: sids.length, samples: rows, bytes: File.size(out) }
275
+ {
276
+ path: out, format: fmt, sessions: sids.length, samples: rows,
277
+ bytes: File.size(out), min_score: min_score, compressed: compress,
278
+ dropped: dropped
279
+ }
234
280
  end
235
281
 
236
282
  # Supported Method Parameters::
@@ -317,6 +363,14 @@ module PWN
317
363
  # S3 — tool-armed constitutional critic runs BEFORE the reward
318
364
  # model so its verdict is evidence, not hindsight.
319
365
  # P7 — force critic when W3 says this engine is overconfident
366
+ # P24 — under budget_exhaustion_hot?, skip tool-armed critic entirely
367
+ # (or single-shot text) so the critic cannot re-thrash the budget.
368
+ budget_hot = begin
369
+ defined?(Loop) && Loop.respond_to?(:budget_exhaustion_hot?, true) &&
370
+ Loop.send(:budget_exhaustion_hot?)
371
+ rescue StandardError
372
+ false
373
+ end
320
374
  force_critic = begin
321
375
  eng = (PWN::Env.dig(:ai, :active) if defined?(PWN::Env))
322
376
  cal = defined?(Metrics) ? Metrics.calibration(engine: eng) : { n: 0 }
@@ -324,7 +378,15 @@ module PWN
324
378
  rescue StandardError
325
379
  false
326
380
  end
327
- crit = if defined?(Curriculum)
381
+ crit = if budget_hot && defined?(Curriculum)
382
+ # P24 — cheap text-only critic: no persona tool swarm
383
+ Curriculum.critic(
384
+ request: opts[:request],
385
+ final: opts[:final],
386
+ session_id: session_id,
387
+ text_only: true
388
+ )
389
+ elsif defined?(Curriculum)
328
390
  if force_critic
329
391
  prev = (PWN::Env[:ai][:agent][:critic] if defined?(PWN::Env) && PWN::Env[:ai].is_a?(Hash) && PWN::Env[:ai][:agent].is_a?(Hash))
330
392
  begin
@@ -349,7 +411,14 @@ module PWN
349
411
  # user correction on the previous turn.
350
412
  pend = Thread.current[:pwn_pending_pref]
351
413
  if pend && ok && defined?(Reward)
352
- Reward.record_preference(prompt: pend[:prompt], rejected: pend[:rejected], chosen: opts[:final].to_s, source: :user_correction)
414
+ Reward.record_preference(
415
+ prompt: pend[:prompt],
416
+ rejected: pend[:rejected],
417
+ chosen: opts[:final].to_s,
418
+ source: :user_correction,
419
+ shape: :revised_answer,
420
+ force: true
421
+ )
353
422
  Thread.current[:pwn_pending_pref] = nil
354
423
  end
355
424
 
@@ -361,13 +430,22 @@ module PWN
361
430
  session_id: session_id,
362
431
  tags: ['auto', 'loop', v[:verdict].to_s]
363
432
  )
433
+ # P20 — fold episode ORM score into per-tool Metrics so UCB/Thompson
434
+ # /advantage track judge, not only handler-ok. Attribution: every
435
+ # tool touched this session gets the episode score (coarse but
436
+ # closes the bandit onto the north-star scalar).
437
+ fold_judge_into_metrics(session_id: session_id, score: v[:score])
364
438
  # R2 — per-step credit assignment ALWAYS (1.6): failed trajectories
365
439
  # are where step credit matters; feed negative steps into counterfactual.
366
440
  # C3 — HER soft-relabel on failure only.
367
441
  Reward.prm(request: opts[:request], session_id: session_id) if defined?(Reward)
368
442
  Curriculum.hindsight(request: opts[:request], final: opts[:final], session_id: session_id) if !ok && defined?(Curriculum)
369
- # W3 — calibration: predicted (from plan_first) vs actual
370
- Curriculum.calibrate(predicted: opts[:predicted], actual: v[:score], engine: PWN::Env.dig(:ai, :active)) if opts[:predicted] && defined?(Curriculum)
443
+ # W3 — calibration: predicted (from plan_first) vs actual.
444
+ # Always try: also recover p(success)= from session PLAN if the
445
+ # live return value was lost (nil) but plan_first did emit one.
446
+ predicted = opts[:predicted]
447
+ predicted = recover_predicted_from_session(session_id: session_id) if predicted.nil?
448
+ Curriculum.calibrate(predicted: predicted, actual: v[:score], engine: PWN::Env.dig(:ai, :active)) if !predicted.nil? && defined?(Curriculum)
371
449
  reflect(session_id: session_id) if ok
372
450
  Reward.sentinel if defined?(Reward)
373
451
  Extrospection.auto_extrospect(session_id: session_id) if defined?(Extrospection)
@@ -473,6 +551,46 @@ module PWN
473
551
  # privates
474
552
  # -------------------------------------------------------------
475
553
 
554
+ # P20 — attribute episode judge score to every tool used in session.
555
+ private_class_method def self.fold_judge_into_metrics(opts = {})
556
+ return unless defined?(Metrics) && Metrics.respond_to?(:record_judge)
557
+
558
+ sid = opts[:session_id]
559
+ score = opts[:score]
560
+ return if sid.to_s.empty? || score.nil?
561
+ return unless defined?(PWN::Sessions)
562
+
563
+ names = PWN::Sessions.load(session_id: sid)
564
+ .select { |e| e[:role].to_s == 'tool' }
565
+ .map { |e| e[:content].to_s[/\A([a-z0-9_]+)\s*→/i, 1] }
566
+ .compact
567
+ .uniq
568
+ names.each { |n| Metrics.record_judge(name: n, score: score) }
569
+ rescue StandardError
570
+ nil
571
+ end
572
+
573
+ # P22 — recover plan_first p(success)= from session transcript when
574
+ # the live return floated away (rescue path / degrade).
575
+ private_class_method def self.recover_predicted_from_session(opts = {})
576
+ # Prefer live stash from plan_first in this process.
577
+ stash = Thread.current[:pwn_plan_predicted]
578
+ return stash.to_f.clamp(0.0, 1.0) unless stash.nil?
579
+
580
+ sid = opts[:session_id]
581
+ return nil if sid.to_s.empty? || !defined?(PWN::Sessions)
582
+
583
+ entries = PWN::Sessions.load(session_id: sid)
584
+ plan = entries.reverse.find do |e|
585
+ e[:role].to_s == 'assistant' && e[:content].to_s.match?(/\bPLAN:\b|p\(success\)\s*=/i)
586
+ end
587
+ return nil unless plan
588
+
589
+ plan[:content].to_s[/p\(success\)\s*=\s*([01](?:\.\d+)?)/i, 1]&.to_f
590
+ rescue StandardError
591
+ nil
592
+ end
593
+
476
594
  private_class_method def self.auto_introspect_enabled?
477
595
  return false unless defined?(PWN::Env) && PWN::Env.is_a?(Hash)
478
596
 
@@ -578,6 +696,34 @@ module PWN
578
696
  warn "[pwn-ai/learning] fact_check swallowed: #{e.class}: #{e.message}"
579
697
  end
580
698
 
699
+ # P12 — PRM-aware SFT trace: keep user + reward>0 tools (else first N) +
700
+ # final assistant, truncate tool payloads. Drops system noise.
701
+ private_class_method def self.compress_finetune_trace(opts = {})
702
+ t = Array(opts[:transcript])
703
+ cap = (opts[:max_tool_chars] || SFT_MAX_TOOL_CHARS).to_i
704
+ fin_idx = t.rindex { |e| e[:role].to_s == 'assistant' && !e[:content].to_s.strip.empty? }
705
+ return [] if fin_idx.nil?
706
+
707
+ user_idx = t[0...fin_idx].rindex { |e| e[:role].to_s == 'user' }
708
+ return [] if user_idx.nil?
709
+
710
+ window = t[user_idx..fin_idx]
711
+ tools = window.select { |e| e[:role].to_s == 'tool' }
712
+ rewarded = tools.select { |e| e[:step_reward].to_i.positive? }
713
+ tools = rewarded unless rewarded.empty?
714
+ tools = tools.first(8)
715
+
716
+ out = []
717
+ out << { role: 'user', content: t[user_idx][:content].to_s[0, 2_000] }
718
+ tools.each do |e|
719
+ out << { role: 'tool', content: e[:content].to_s[0, cap] }
720
+ end
721
+ out << { role: 'assistant', content: t[fin_idx][:content].to_s[0, 4_000] }
722
+ out
723
+ rescue StandardError
724
+ []
725
+ end
726
+
581
727
  private_class_method def self.compress_exemplar(opts = {})
582
728
  sid = opts[:session_id]
583
729
  cap = opts[:max_msgs] || 6
@@ -51,6 +51,10 @@ module PWN
51
51
  module Loop
52
52
  DEFAULT_MAX_ITERS = 777
53
53
  ESCALATE_AFTER_FAILS = 4
54
+ # P17 — when empty_final / known thrash shapes dominate, stop before
55
+ # burning the full ollama cap so the corpus is not pure terminal failure.
56
+ BUDGET_HARD_STOP_FAILS = 8
57
+ BUDGET_EMPTY_FINAL_STOP = 3
54
58
 
55
59
  ENGINE_MODS = {
56
60
  openai: 'PWN::AI::OpenAI',
@@ -77,6 +81,25 @@ module PWN
77
81
  { role: 'assistant', content: txt, tool_calls: [] }
78
82
  end
79
83
 
84
+ # P17 — true when unresolved agent_loop / assistant_answer budget
85
+ # fingerprints dominate Mistakes.top (the #1 live failure mode).
86
+ private_class_method def self.budget_exhaustion_hot?
87
+ return false unless defined?(Mistakes)
88
+
89
+ top = Mistakes.top(limit: 8, unresolved_only: true)
90
+ return false if top.empty?
91
+
92
+ budgetish = top.count do |m|
93
+ t = m[:tool].to_s
94
+ e = m[:error].to_s.downcase
95
+ t == 'agent_loop' || t == 'assistant_answer' ||
96
+ e.include?('budget exhausted') || e.include?('iteration budget')
97
+ end
98
+ budgetish >= 2 || (budgetish >= 1 && top.first[:tool].to_s == 'agent_loop')
99
+ rescue StandardError
100
+ false
101
+ end
102
+
80
103
  private_class_method def self.max_iters
81
104
  v = (PWN::Env.dig(:ai, :agent, :max_iters) if defined?(PWN::Env))
82
105
  n = v.to_i.positive? ? v.to_i : DEFAULT_MAX_ITERS
@@ -87,6 +110,11 @@ module PWN
87
110
  # shrink the tool budget so thrash can't compound on bad plans.
88
111
  cal = calibration_state
89
112
  n = [n, cal[:max_iters_cap]].min if cal[:overconfident]
113
+ # P17 — tighter default when agent_loop budget_exhaustion dominates
114
+ # open mistakes: finish-under-N is the skill gap, not more thrash.
115
+ if budget_exhaustion_hot?
116
+ n = [n, active_engine == :ollama ? 12 : 20].min
117
+ end
90
118
  n
91
119
  rescue StandardError
92
120
  DEFAULT_MAX_ITERS
@@ -247,8 +275,15 @@ module PWN
247
275
  rt = Curriculum.red_team_plan(request: opts[:request], plan: plan)
248
276
  messages << { role: 'user', content: rt } if rt
249
277
  end
250
- # W3 — extract predicted p(success) for calibration tracking
251
- plan[/p\(success\)\s*=\s*([01](?:\.\d+)?)/i, 1]&.to_f
278
+ # W3/P22 — extract predicted p(success) for calibration tracking.
279
+ # Accept p(success)=0.7 | p(success) = .7 | confidence=0.7 on last lines.
280
+ predicted = plan[/p\(\s*success\s*\)\s*=\s*([01]?(?:\.\d+)?)/i, 1]&.to_f
281
+ predicted = plan[/\bconfidence\s*=\s*([01]?(?:\.\d+)?)/i, 1]&.to_f if predicted.nil?
282
+ predicted = predicted.clamp(0.0, 1.0) if predicted
283
+ # Stash so auto_introspect / recover can always see it even if the
284
+ # return value is dropped by a caller rescue.
285
+ Thread.current[:pwn_plan_predicted] = predicted
286
+ predicted
252
287
  rescue StandardError => e
253
288
  warn "[pwn-ai/loop] plan_first swallowed: #{e.class}: #{e.message}"
254
289
  nil
@@ -498,9 +533,20 @@ module PWN
498
533
  append_session(session_id: session_id, role: 'user', content: request)
499
534
 
500
535
  predicted = nil
536
+ Thread.current[:pwn_plan_predicted] = nil
501
537
  cal_state = calibration_state
502
538
  force_plan = cal_state[:force_plan]
503
- predicted = plan_first(messages: messages, request: request) if (force_plan || agent_flag(key: :plan_first, default: local)) && !Array(tools).empty?
539
+ if (force_plan || agent_flag(key: :plan_first, default: local) || budget_exhaustion_hot?) && !Array(tools).empty?
540
+ predicted = plan_first(messages: messages, request: request)
541
+ # P22 — prefer explicit return; fall back to thread stash
542
+ predicted = Thread.current[:pwn_plan_predicted] if predicted.nil?
543
+ end
544
+ if budget_exhaustion_hot?
545
+ messages << {
546
+ role: 'user',
547
+ content: '[pwn-ai/p17] Budget-exhaustion is the top open failure on this host. Prefer the SHORTEST plan that finishes the ask (≤3 tool calls). Emit a final answer as soon as you have evidence — do not explore.'
548
+ }
549
+ end
504
550
  if force_plan && cal_state[:cal]
505
551
  messages << {
506
552
  role: 'user',
@@ -589,6 +635,31 @@ module PWN
589
635
  )
590
636
  end
591
637
 
638
+ # P17 — hard stop: empty-final thrash or cumulative fails past cap.
639
+ # Prefer a short apologetic final over another 10 useless tool dumps
640
+ # that poison ORM/PRM/DPO with terminal failures.
641
+ empty_n = turn_fails['empty_final'].to_i
642
+ fail_n = turn_fails.values.sum
643
+ if empty_n >= BUDGET_EMPTY_FINAL_STOP || fail_n >= BUDGET_HARD_STOP_FAILS
644
+ msg = if empty_n >= BUDGET_EMPTY_FINAL_STOP
645
+ '[pwn-ai] stopped: repeated empty finals (budget thrash guard)'
646
+ else
647
+ '[pwn-ai] stopped: too many in-turn failures (budget thrash guard)'
648
+ end
649
+ if defined?(Mistakes)
650
+ Mistakes.record(
651
+ tool: 'agent_loop',
652
+ error: "budget thrash guard fired empty=#{empty_n} fails=#{fail_n} iter=#{i}",
653
+ session_id: session_id,
654
+ source: :loop,
655
+ shape: :budget_thrash
656
+ )
657
+ end
658
+ append_session(session_id: session_id, role: 'assistant', content: msg)
659
+ Learning.auto_introspect(session_id: session_id, request: request, final: msg, predicted: predicted) if defined?(Learning) && should_auto_introspect?(local: local, turn_fails: turn_fails, iter: i)
660
+ return msg
661
+ end
662
+
592
663
  next unless local && !escalated && turn_fails.values.sum >= ESCALATE_AFTER_FAILS
593
664
 
594
665
  hint = escalate(request: request, turn_fails: turn_fails, session_id: session_id)
@@ -599,7 +670,7 @@ module PWN
599
670
  escalated = true
600
671
  end
601
672
 
602
- Mistakes.record(tool: 'agent_loop', error: 'iteration budget exhausted without a final answer', session_id: session_id, source: :loop) if defined?(Mistakes)
673
+ Mistakes.record(tool: 'agent_loop', error: 'iteration budget exhausted without a final answer', session_id: session_id, source: :loop, shape: :budget_exhausted) if defined?(Mistakes)
603
674
  '[pwn-ai] iteration budget exhausted'
604
675
  end
605
676
 
@@ -122,6 +122,8 @@ module PWN
122
122
  name: name.to_s,
123
123
  calls: calls,
124
124
  success_rate: rate,
125
+ judge_rate: judge_rate(name: name),
126
+ effective_rate: effective_rate(name: name),
125
127
  avg_duration: avg,
126
128
  last_error: b[:last_error],
127
129
  last_at: b[:last_at]
@@ -149,12 +151,19 @@ module PWN
149
151
  scope = engine.to_s.empty? ? 'historical' : "engine=#{engine}"
150
152
  scope = "#{scope}, proxy_distrust=#{distrust.round(2)}" if distrust.positive?
151
153
  lines = rows.map do |r|
154
+ # P20 — display effective_rate (judge-blended) when available;
155
+ # fall back to distrust haircut on raw proxy.
152
156
  rate = r[:success_rate].to_f
153
- # blend toward 0.5 (uninformative) proportional to distrust
154
- adj = rate - ((rate - 0.5) * distrust)
157
+ eff = r[:effective_rate]
158
+ adj = if eff
159
+ eff.to_f
160
+ else
161
+ rate - ((rate - 0.5) * distrust)
162
+ end
155
163
  err = r[:last_error] ? " last_err=#{r[:last_error][0, 60]}" : ''
156
- tag = distrust.positive? ? ' (adj)' : ''
157
- " - #{r[:name]}: calls=#{r[:calls]} success=#{(adj * 100).round(1)}%#{tag} avg=#{r[:avg_duration]}s#{err}"
164
+ jtag = r[:judge_rate] ? " judge=#{(r[:judge_rate].to_f * 100).round(1)}%" : ''
165
+ tag = distrust.positive? || r[:judge_rate] ? ' (adj)' : ''
166
+ " - #{r[:name]}: calls=#{r[:calls]} success=#{(adj * 100).round(1)}%#{tag}#{jtag} avg=#{r[:avg_duration]}s#{err}"
158
167
  end
159
168
  warn_line = distrust.positive? ? "WARNING: reward proxy diverges from judge — success rates haircut by distrust=#{distrust.round(2)}; prefer judge-scored exemplars over raw rates.\n" : ''
160
169
  "#{warn_line}TOOL EFFECTIVENESS (#{scope}, adapt tool choice accordingly)\n#{lines.join("\n")}\n\n"
@@ -176,7 +185,8 @@ module PWN
176
185
  t = data[name.to_sym] || blank_bucket
177
186
  n = [t[:calls].to_f, 1.0].max
178
187
  total = [data.values.sum { |v| v[:calls].to_f }, 1.0].max
179
- mean = t[:ok].to_f / n
188
+ # P20 — mean from effective_rate (judge-blended when distrust high)
189
+ mean = effective_rate(name: name)
180
190
  mean + (c * Math.sqrt(Math.log(total) / n))
181
191
  rescue StandardError
182
192
  1.0
@@ -190,7 +200,21 @@ module PWN
190
200
 
191
201
  public_class_method def self.thompson(opts = {})
192
202
  t = (load[:tools] || {})[opts[:name].to_s.to_sym] || blank_bucket
193
- beta_sample(alpha: t[:ok].to_f + 1.0, beta: t[:fail].to_f + 1.0)
203
+ # P20 when judge samples exist and distrust > 0, tilt Beta toward
204
+ # judge_rate so Thompson explore/exploit tracks ORM not handler-ok.
205
+ ok = t[:ok].to_f
206
+ fail = t[:fail].to_f
207
+ jn = Array(t[:judge_window]).length
208
+ if jn >= 3 && proxy_trust < 0.95
209
+ jr = judge_rate(name: opts[:name]).to_f
210
+ # pseudo-counts from judge window, mixed by distrust
211
+ d = (1.0 - proxy_trust).clamp(0.0, 1.0)
212
+ jok = jr * jn
213
+ jfail = (1.0 - jr) * jn
214
+ ok = (ok * (1.0 - d)) + (jok * d)
215
+ fail = (fail * (1.0 - d)) + (jfail * d)
216
+ end
217
+ beta_sample(alpha: ok + 1.0, beta: fail + 1.0)
194
218
  rescue StandardError
195
219
  0.5
196
220
  end
@@ -205,14 +229,117 @@ module PWN
205
229
  t = data[opts[:name].to_s.to_sym]
206
230
  return 0.0 unless t
207
231
 
208
- global = data.values.sum { |v| v[:ok].to_f } / [data.values.sum { |v| v[:calls].to_f }, 1.0].max
209
- win = Array(t[:window])
210
- local = win.empty? ? (t[:ok].to_f / [t[:calls].to_f, 1.0].max) : (win.sum.to_f / win.length)
232
+ # P20 local/global from effective_rate so bandit tracks judge
233
+ # when the handler-ok proxy is hacked (proxy_distrust high).
234
+ local = effective_rate(name: opts[:name])
235
+ rates = data.keys.map { |k| effective_rate(name: k) }
236
+ global = rates.empty? ? 0.5 : (rates.sum / rates.length)
211
237
  (local - global).round(3)
212
238
  rescue StandardError
213
239
  0.0
214
240
  end
215
241
 
242
+ # P18 — rolling mean step_reward for a tool (from Reward.prm annotations
243
+ # folded into Metrics via record_step_reward). Range ≈ [-1, 1].
244
+ public_class_method def self.prm_advantage(opts = {})
245
+ data = load[:tools] || {}
246
+ t = data[opts[:name].to_s.to_sym]
247
+ return 0.0 unless t
248
+
249
+ win = Array(t[:prm_window])
250
+ return 0.0 if win.empty?
251
+
252
+ mean = win.sum.to_f / win.length
253
+ # global mean across tools that have prm signal
254
+ globals = data.values.map { |v| Array(v[:prm_window]) }.reject(&:empty?)
255
+ gmean = if globals.empty?
256
+ 0.0
257
+ else
258
+ all = globals.flatten
259
+ all.sum.to_f / all.length
260
+ end
261
+ (mean - gmean).round(3)
262
+ rescue StandardError
263
+ 0.0
264
+ end
265
+
266
+ # P18 — called by Reward.prm after session annotate so live routing
267
+ # can bias toward tools that recently advanced goals (+1 step_reward).
268
+ public_class_method def self.record_step_reward(opts = {})
269
+ name = opts[:name].to_s
270
+ return if name.empty?
271
+
272
+ rew = opts[:reward].to_f.clamp(-1.0, 1.0)
273
+ m = load
274
+ m[:tools] ||= {}
275
+ t = m[:tools][name.to_sym] ||= blank_bucket
276
+ t[:prm_window] = (Array(t[:prm_window]) + [rew]).last(40)
277
+ t[:prm_sum] = t[:prm_window].sum
278
+ t[:prm_n] = t[:prm_window].length
279
+ save(metrics: m)
280
+ t
281
+ rescue StandardError
282
+ nil
283
+ end
284
+
285
+ # P20 — fold ORM judge (0..1) into per-tool telemetry so UCB /
286
+ # Thompson / advantage can prefer judge-grounded rates over the
287
+ # inflated handler-ok proxy when proxy_distrust is high.
288
+ public_class_method def self.record_judge(opts = {})
289
+ name = opts[:name].to_s
290
+ return if name.empty?
291
+
292
+ score = opts[:score].to_f.clamp(0.0, 1.0)
293
+ m = load
294
+ m[:tools] ||= {}
295
+ t = m[:tools][name.to_sym] ||= blank_bucket
296
+ t[:judge_window] = (Array(t[:judge_window]) + [score]).last(40)
297
+ t[:judge_sum] = t[:judge_window].sum.to_f
298
+ t[:judge_n] = t[:judge_window].length
299
+ save(metrics: m)
300
+ t
301
+ rescue StandardError
302
+ nil
303
+ end
304
+
305
+ # Mean judge score for a tool (nil when no ORM samples yet).
306
+ public_class_method def self.judge_rate(opts = {})
307
+ t = (load[:tools] || {})[opts[:name].to_s.to_sym]
308
+ return nil unless t
309
+
310
+ win = Array(t[:judge_window])
311
+ return nil if win.empty?
312
+
313
+ (win.sum.to_f / win.length).round(3)
314
+ rescue StandardError
315
+ nil
316
+ end
317
+
318
+ # Blended success rate: when proxy_distrust > 0 and judge samples
319
+ # exist, mix judge_rate into the handler-ok rate. distrust=1 → pure
320
+ # judge (or 0.5 if no judge data). distrust=0 → pure proxy.
321
+ public_class_method def self.effective_rate(opts = {})
322
+ name = opts[:name].to_s
323
+ data = load[:tools] || {}
324
+ t = data[name.to_sym]
325
+ return 0.5 unless t
326
+
327
+ calls = [t[:calls].to_f, 1.0].max
328
+ proxy = t[:ok].to_f / calls
329
+ win = Array(t[:window])
330
+ proxy = win.sum.to_f / win.length if win.length >= 3
331
+ distrust = 1.0 - proxy_trust
332
+ jrate = judge_rate(name: name)
333
+ if distrust > 0.05 && !jrate.nil?
334
+ # blend: higher distrust → more judge weight
335
+ ((proxy * (1.0 - distrust)) + (jrate * distrust)).clamp(0.0, 1.0).round(3)
336
+ else
337
+ proxy.round(3)
338
+ end
339
+ rescue StandardError
340
+ 0.5
341
+ end
342
+
216
343
  # Supported Method Parameters::
217
344
  # cps = PWN::AI::Agent::Metrics.changepoints
218
345
  #
@@ -360,7 +487,10 @@ module PWN
360
487
  PWN::AI::Agent::Metrics.to_context(limit: 8, engine: :ollama) # injected by PromptBuilder
361
488
  PWN::AI::Agent::Metrics.ucb(name: 'shell') # C1 exploration bonus
362
489
  PWN::AI::Agent::Metrics.thompson(name: 'shell') # C1 Beta(ok+1,fail+1) sample
363
- PWN::AI::Agent::Metrics.advantage(name: 'shell') # C1 local − global
490
+ PWN::AI::Agent::Metrics.advantage(name: 'shell') # C1 local − global (P20 judge-blended)
491
+ PWN::AI::Agent::Metrics.record_judge(name: 'shell', score: 0.8) # P20 ORM→metrics
492
+ PWN::AI::Agent::Metrics.judge_rate(name: 'shell') # P20 mean ORM
493
+ PWN::AI::Agent::Metrics.effective_rate(name: 'shell') # P20 proxy⋈judge
364
494
  PWN::AI::Agent::Metrics.changepoints(within_secs: 3600) # E1 CUSUM regime changes
365
495
  PWN::AI::Agent::Metrics.record_calibration(predicted: 0.8, actual: 1.0, brier: 0.04, engine: :ollama)
366
496
  PWN::AI::Agent::Metrics.calibration(engine: :ollama) # W3 Brier / overconfidence
@@ -257,15 +257,33 @@ module PWN
257
257
  importance: 0.9
258
258
  )
259
259
  end
260
- # W1 — every resolve is a naturally-generated preference pair:
261
- # (rejected: the failing action, chosen: the fix).
260
+ # W1/P9 — every resolve is a preference pair. Prefer structured
261
+ # winning_trace (+ strategy/tool) over first-line fix prose so DPO
262
+ # learns tool trajectories, not commentary.
262
263
  if defined?(Reward)
263
- Reward.record_preference(
264
- prompt: "#{store[key][:tool]}: #{store[key][:error]}",
265
- rejected: store[key][:snippet].to_s,
266
- chosen: fix.strip,
267
- source: :mistakes_resolve
268
- )
264
+ sf = store[key][:structured_fix] || {}
265
+ trace = sf[:winning_trace].to_s.strip
266
+ strat = [sf[:strategy], sf[:tool], sf[:args_template]].compact.map(&:to_s).reject(&:empty?).join(' | ')
267
+ # P21/P25 — only write W1 pairs when we have a real winning_trace.
268
+ # Prose-only resolve still updates Memory lesson + structured_fix;
269
+ # it must NOT flood DPO with fix commentary (shape: :fix_prose).
270
+ if trace.length >= 40
271
+ parts = []
272
+ parts << "STRATEGY: #{strat}" unless strat.empty?
273
+ parts << "WINNING_TRACE:\n#{trace[0, 3_500]}"
274
+ parts << "FIX: #{fix.strip[0, 400]}"
275
+ chosen = parts.join("\n")
276
+ rejected = store[key][:snippet].to_s
277
+ rejected = "FAILING: tool=#{store[key][:tool]} err=#{store[key][:error]}" if rejected.strip.empty?
278
+ Reward.record_preference(
279
+ prompt: "#{store[key][:tool]}: #{store[key][:error]}",
280
+ rejected: rejected,
281
+ chosen: chosen,
282
+ source: :mistakes_resolve,
283
+ shape: :winning_trace,
284
+ meta: { signature: sig, strategy: sf[:strategy], tool: sf[:tool] }.compact
285
+ )
286
+ end
269
287
  end
270
288
  store[key]
271
289
  end
@@ -136,7 +136,7 @@ module PWN
136
136
 
137
137
  tokens = query.scan(/[a-z0-9_]{3,}/).uniq
138
138
  # C1 — advantage-weighted router:
139
- # score = α·keyword_sim + β·advantage + γ·UCB(tool)
139
+ # score = α·keyword_sim + β·advantage + γ·UCB(tool) + δ·prm_advantage
140
140
  # UCB gives untried / low-N tools an exploration bonus so a single
141
141
  # early failure (before its dep was installed) does not blacklist
142
142
  # it forever; advantage prefers tools that outperform the fleet.
@@ -145,12 +145,15 @@ module PWN
145
145
  trust = defined?(Metrics) && Metrics.respond_to?(:proxy_trust) ? Metrics.proxy_trust : 1.0
146
146
  beta = 0.3 * trust
147
147
  gamma = 0.2
148
+ # P18 — PRM step_reward advantage closes R2 into the controller
149
+ delta = 0.25 * trust
148
150
  scored = entries.map do |e|
149
151
  hay = "#{e.name} #{e.toolset} #{e.schema[:description]} #{Array(e.schema.dig(:parameters, :properties)&.keys).join(' ')}".downcase
150
152
  sim = tokens.count { |t| hay.include?(t) }
151
153
  adv = defined?(Metrics) && Metrics.respond_to?(:advantage) ? Metrics.advantage(name: e.name) : 0.0
152
154
  ucb = defined?(Metrics) && Metrics.respond_to?(:ucb) ? Metrics.ucb(name: e.name) : 0.5
153
- [e, sim, (alpha * sim) + (beta * adv) + (gamma * ucb)]
155
+ prm = defined?(Metrics) && Metrics.respond_to?(:prm_advantage) ? Metrics.prm_advantage(name: e.name) : 0.0
156
+ [e, sim, (alpha * sim) + (beta * adv) + (gamma * ucb) + (delta * prm)]
154
157
  end
155
158
  scored.reject { |_, sim, _| sim.zero? }
156
159
  .sort_by { |_, _, s| -s }