pwn 0.5.647 → 0.5.650

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.
@@ -25,8 +25,41 @@ module PWN
25
25
  module Learning
26
26
  LEARNING_FILE = File.join(Dir.home, '.pwn', 'learning.jsonl')
27
27
  FINETUNE_DIR = File.join(Dir.home, '.pwn', 'finetune')
28
+ # P0 — post-answer introspect must not train "stop early" while
29
+ # spending the iteration budget after the final. Soft cap skips
30
+ # expensive stages (tool critic, PRM-LLM, reflect, extrospect);
31
+ # hard cap keeps only note_outcome + judge(heuristic) + sentinel.
32
+ INTROSPECT_SOFT_MS = 2_500
33
+ INTROSPECT_HARD_MS = 8_000
34
+ INTROSPECT_MIN_STAGES = %i[judge note_outcome fold_judge sentinel].freeze
35
+
28
36
  MAX_MEMORY_ENTRIES = 200
29
- CLAIM_RX = /CVE-\d{4}-\d{4,7}|\b[A-Za-z][\w.+-]{2,}\s+v?\d+\.\d+(?:\.\d+)?\b/
37
+ # E3/P26 — only CVE-ids or software-name + full semver (x.y.z).
38
+ # Two-part floats ("cap 0.2", "proxy 1.0", "judge 37.0") are RL
39
+ # metric crumbs that were scraped by verify_as_reward and flooded
40
+ # learning.jsonl with extro_verify :unknown failures.
41
+ CLAIM_METRIC_WORDS = %w[
42
+ cap share proxy judge success only now clears gap score rate mean
43
+ brier overconf distrust trajectory_fraction handler orm prm delta
44
+ limit window pct percent ms iter budget conf confidence n
45
+ ruby python linux kernel host cwd e.g e.g.
46
+ ].freeze
47
+ CLAIM_RX = /
48
+ CVE-\d{4}-\d{4,7}
49
+ |
50
+ \b
51
+ (?!
52
+ (?:cap|share|proxy|judge|success|only|now|clears|gap|score|rate|mean|
53
+ brier|overconf|distrust|trajectory_fraction|handler|orm|prm|delta|
54
+ limit|window|pct|percent|ms|iter|budget|conf|confidence|
55
+ ruby|python|linux|kernel|host|cwd|e\.g)
56
+ \b
57
+ )
58
+ [A-Za-z][\w.+-]{2,}
59
+ \s+
60
+ v?\d+\.\d+\.\d+(?:[-+][\w.]+)?
61
+ \b
62
+ /x
30
63
 
31
64
  # Supported Method Parameters::
32
65
  # entry = PWN::AI::Agent::Learning.note_outcome(
@@ -359,18 +392,33 @@ module PWN
359
392
  return unless session_id
360
393
  return unless auto_introspect_enabled?
361
394
 
362
- proxy_ok = infer_success(session_id: session_id, final: opts[:final])
363
- # S3 — tool-armed constitutional critic runs BEFORE the reward
364
- # model so its verdict is evidence, not hindsight.
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.
395
+ t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
396
+ stages_run = []
397
+ stages_skipped = []
368
398
  budget_hot = begin
369
399
  defined?(Loop) && Loop.respond_to?(:budget_exhaustion_hot?, true) &&
370
400
  Loop.send(:budget_exhaustion_hot?)
371
401
  rescue StandardError
372
402
  false
373
403
  end
404
+
405
+ elapsed_ms = lambda do
406
+ ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0) * 1000).round
407
+ end
408
+ # soft = skip expensive; hard = stop almost everything
409
+ over_soft = lambda do
410
+ ms = elapsed_ms.call
411
+ ms >= INTROSPECT_SOFT_MS || budget_hot
412
+ end
413
+ over_hard = lambda do
414
+ ms = elapsed_ms.call
415
+ ms >= INTROSPECT_HARD_MS
416
+ end
417
+
418
+ proxy_ok = infer_success(session_id: session_id, final: opts[:final])
419
+
420
+ # S3 critic — BEFORE reward so verdict is evidence.
421
+ # P24/P0 — budget_hot or soft-cap → text_only or skip.
374
422
  force_critic = begin
375
423
  eng = (PWN::Env.dig(:ai, :active) if defined?(PWN::Env))
376
424
  cal = defined?(Metrics) ? Metrics.calibration(engine: eng) : { n: 0 }
@@ -378,39 +426,51 @@ module PWN
378
426
  rescue StandardError
379
427
  false
380
428
  end
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)
390
- if force_critic
391
- prev = (PWN::Env[:ai][:agent][:critic] if defined?(PWN::Env) && PWN::Env[:ai].is_a?(Hash) && PWN::Env[:ai][:agent].is_a?(Hash))
392
- begin
393
- PWN::Env[:ai][:agent][:critic] = true if defined?(PWN::Env) && PWN::Env[:ai].is_a?(Hash) && PWN::Env[:ai][:agent].is_a?(Hash) && !PWN::Env[:ai][:agent].frozen?
394
- Curriculum.critic(request: opts[:request], final: opts[:final], session_id: session_id)
395
- ensure
396
- PWN::Env[:ai][:agent][:critic] = prev if defined?(PWN::Env) && PWN::Env[:ai].is_a?(Hash) && PWN::Env[:ai][:agent].is_a?(Hash) && !PWN::Env[:ai][:agent].frozen?
397
- end
398
- else
399
- Curriculum.critic(request: opts[:request], final: opts[:final], session_id: session_id)
400
- end
401
- else
402
- { verdict: :pass }
403
- end
404
- # R1 — LLM Outcome Reward Model (falls back to calibrated heuristic)
429
+ # P0 when W1 mix urgently needs :critic pairs, prefer running critic
430
+ need_critic_mix = begin
431
+ mix = defined?(Reward) && Reward.respond_to?(:generator_mix) ? Reward.generator_mix : {}
432
+ Array(mix[:urgent]).include?('critic')
433
+ rescue StandardError
434
+ false
435
+ end
436
+
437
+ crit = { verdict: :pass, source: :skipped }
438
+ # Single skip path (Lint/DuplicateBranch): hard-budget OR no Curriculum.
439
+ if !defined?(Curriculum) || (over_hard.call && !need_critic_mix)
440
+ stages_skipped << :critic
441
+ elsif budget_hot || (over_soft.call && !force_critic && !need_critic_mix)
442
+ stages_run << :critic_text_only
443
+ crit = Curriculum.critic(
444
+ request: opts[:request],
445
+ final: opts[:final],
446
+ session_id: session_id,
447
+ text_only: true
448
+ )
449
+ elsif force_critic
450
+ stages_run << :critic_forced
451
+ prev = (PWN::Env[:ai][:agent][:critic] if defined?(PWN::Env) && PWN::Env[:ai].is_a?(Hash) && PWN::Env[:ai][:agent].is_a?(Hash))
452
+ begin
453
+ PWN::Env[:ai][:agent][:critic] = true if defined?(PWN::Env) && PWN::Env[:ai].is_a?(Hash) && PWN::Env[:ai][:agent].is_a?(Hash) && !PWN::Env[:ai][:agent].frozen?
454
+ crit = Curriculum.critic(request: opts[:request], final: opts[:final], session_id: session_id)
455
+ ensure
456
+ PWN::Env[:ai][:agent][:critic] = prev if defined?(PWN::Env) && PWN::Env[:ai].is_a?(Hash) && PWN::Env[:ai][:agent].is_a?(Hash) && !PWN::Env[:ai][:agent].frozen?
457
+ end
458
+ else
459
+ stages_run << :critic
460
+ crit = Curriculum.critic(request: opts[:request], final: opts[:final], session_id: session_id)
461
+ end
462
+
463
+ # R1 judge — always attempt (heuristic is cheap; LLM gated inside)
464
+ stages_run << :judge
405
465
  v = Reward.judge(request: opts[:request], final: opts[:final], session_id: session_id, proxy_ok: proxy_ok) if defined?(Reward)
406
466
  v ||= { score: proxy_ok ? 1.0 : 0.0, success: proxy_ok, verdict: proxy_ok ? :solved : :wrong }
407
467
  v[:score] = [v[:score], 0.3].min if crit[:verdict] == :flaw
408
468
  ok = v[:score] >= 0.6
409
469
 
410
- # W1 — complete any pending (rejected, chosen) pair from a
411
- # user correction on the previous turn.
470
+ # W1 pending user_correction pair
412
471
  pend = Thread.current[:pwn_pending_pref]
413
472
  if pend && ok && defined?(Reward)
473
+ stages_run << :user_correction_pref
414
474
  Reward.record_preference(
415
475
  prompt: pend[:prompt],
416
476
  rejected: pend[:rejected],
@@ -422,6 +482,7 @@ module PWN
422
482
  Thread.current[:pwn_pending_pref] = nil
423
483
  end
424
484
 
485
+ stages_run << :note_outcome
425
486
  note_outcome(
426
487
  task: opts[:request].to_s[0, 120],
427
488
  success: ok,
@@ -430,41 +491,69 @@ module PWN
430
491
  session_id: session_id,
431
492
  tags: ['auto', 'loop', v[:verdict].to_s]
432
493
  )
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])
438
- # R2 — per-step credit assignment ALWAYS (1.6): failed trajectories
439
- # are where step credit matters; feed negative steps into counterfactual.
440
- # C3 — HER soft-relabel on failure only.
441
- Reward.prm(request: opts[:request], session_id: session_id) if defined?(Reward)
442
- Curriculum.hindsight(request: opts[:request], final: opts[:final], session_id: session_id) if !ok && 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.
494
+
495
+ stages_run << :fold_judge
496
+ fold_judge_into_metrics(session_id: session_id, score: v[:score], confidence: v[:confidence])
497
+
498
+ # R2 PRM — skip under hard cap (expensive LLM); keep under soft if heuristic path
499
+ if over_hard.call
500
+ stages_skipped << :prm
501
+ elsif defined?(Reward)
502
+ stages_run << :prm
503
+ Reward.prm(request: opts[:request], session_id: session_id)
504
+ end
505
+
506
+ # C3 HER only on failure; skip hard
507
+ if !ok && defined?(Curriculum) && !over_hard.call
508
+ stages_run << :hindsight
509
+ Curriculum.hindsight(request: opts[:request], final: opts[:final], session_id: session_id)
510
+ else
511
+ stages_skipped << :hindsight unless ok
512
+ end
513
+
514
+ # W3 calibrate — cheap; always when predicted available
446
515
  predicted = opts[:predicted]
447
516
  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)
449
- reflect(session_id: session_id) if ok
450
- Reward.sentinel if defined?(Reward)
451
- Extrospection.auto_extrospect(session_id: session_id) if defined?(Extrospection)
517
+ if !predicted.nil? && defined?(Curriculum)
518
+ stages_run << :calibrate
519
+ Curriculum.calibrate(predicted: predicted, actual: v[:score], engine: PWN::Env.dig(:ai, :active))
520
+ end
521
+
522
+ # reflect on success — skip soft/hard (LLM + memory writes)
523
+ if ok && !over_soft.call
524
+ stages_run << :reflect
525
+ reflect(session_id: session_id)
526
+ elsif ok
527
+ stages_skipped << :reflect
528
+ end
529
+
530
+ # R3 sentinel — cheap disk math; always
531
+ if defined?(Reward)
532
+ stages_run << :sentinel
533
+ Reward.sentinel
534
+ end
535
+
536
+ # E ambient extrospect — skip soft (can launch probes)
537
+ if defined?(Extrospection) && !over_soft.call
538
+ stages_run << :extrospect
539
+ Extrospection.auto_extrospect(session_id: session_id)
540
+ else
541
+ stages_skipped << :extrospect
542
+ end
543
+
544
+ {
545
+ ok: ok,
546
+ score: v[:score],
547
+ elapsed_ms: elapsed_ms.call,
548
+ budget_hot: budget_hot,
549
+ stages_run: stages_run,
550
+ stages_skipped: stages_skipped
551
+ }
452
552
  rescue StandardError => e
453
553
  warn "[pwn-ai/learning] auto_introspect swallowed: #{e.class}: #{e.message}"
454
554
  nil
455
555
  end
456
556
 
457
- # Supported Method Parameters::
458
- # PWN::AI::Agent::Learning.flip_last_outcome(
459
- # session_id: 'optional - only flip if the newest outcome belongs to this session',
460
- # reason: 'optional - why it is being flipped (usually the user correction text)'
461
- # )
462
- #
463
- # Rewrites the most-recently-appended learning.jsonl entry from
464
- # success:true to success:false. Called by Mistakes.check_user_correction
465
- # when the user's next message rejects the previous answer, so the
466
- # 100 %-success illusion is broken and the failure enters the corpus.
467
-
468
557
  public_class_method def self.flip_last_outcome(opts = {})
469
558
  return { flipped: false } unless File.exist?(LEARNING_FILE)
470
559
 
@@ -557,6 +646,7 @@ module PWN
557
646
 
558
647
  sid = opts[:session_id]
559
648
  score = opts[:score]
649
+ conf = opts[:confidence]
560
650
  return if sid.to_s.empty? || score.nil?
561
651
  return unless defined?(PWN::Sessions)
562
652
 
@@ -565,7 +655,7 @@ module PWN
565
655
  .map { |e| e[:content].to_s[/\A([a-z0-9_]+)\s*→/i, 1] }
566
656
  .compact
567
657
  .uniq
568
- names.each { |n| Metrics.record_judge(name: n, score: score) }
658
+ names.each { |n| Metrics.record_judge(name: n, score: score, confidence: conf) }
569
659
  rescue StandardError
570
660
  nil
571
661
  end
@@ -680,6 +770,23 @@ module PWN
680
770
  lessons.uniq.first(5)
681
771
  end
682
772
 
773
+ # P26 — is this CLAIM_RX hit worth spending a headless browser on?
774
+ # Reject metric crumbs, OS/runtime banner lines, and bare versions.
775
+ private_class_method def self.checkable_claim?(opts = {})
776
+ c = opts[:claim].to_s.strip
777
+ return false if c.empty? || c.length < 8
778
+ return true if c.match?(/CVE-\d{4}-\d{4,7}/i)
779
+
780
+ head = c[/\A[A-Za-z][\w.+-]*/].to_s
781
+ return false if CLAIM_METRIC_WORDS.any? { |w| head.casecmp?(w) }
782
+ # require full semver x.y.z for non-CVE claims
783
+ return false unless c.match?(/\bv?\d+\.\d+\.\d+/)
784
+
785
+ true
786
+ rescue StandardError
787
+ false
788
+ end
789
+
683
790
  # Auto fact-check post-filter: local models hallucinate CVEs /
684
791
  # versions ~5-10x more than frontier ones. When the active engine is
685
792
  # :ollama, scan the final for CVE / version-shaped claims and hand
@@ -690,7 +797,8 @@ module PWN
690
797
  return unless defined?(PWN::Env) && PWN::Env.dig(:ai, :active).to_s.downcase.to_sym == :ollama
691
798
  return unless defined?(Extrospection) && Extrospection.respond_to?(:verify)
692
799
 
693
- claims = opts[:final].to_s.scan(CLAIM_RX).flatten.compact.uniq.first(3)
800
+ claims = opts[:final].to_s.scan(CLAIM_RX).flatten.compact.uniq
801
+ claims = claims.select { |c| checkable_claim?(claim: c) }.first(3)
694
802
  claims.each { |c| Extrospection.verify(claim: c, commit: true) }
695
803
  rescue StandardError => e
696
804
  warn "[pwn-ai/learning] fact_check swallowed: #{e.class}: #{e.message}"
@@ -112,8 +112,15 @@ module PWN
112
112
  n = [n, cal[:max_iters_cap]].min if cal[:overconfident]
113
113
  # P17 — tighter default when agent_loop budget_exhaustion dominates
114
114
  # open mistakes: finish-under-N is the skill gap, not more thrash.
115
+ # Caps are intentionally harsh (8 local / 12 remote): the open
116
+ # fingerprint is "iteration budget exhausted" ×N; more headroom
117
+ # only produces more empty terminal failures for ORM/PRM/DPO.
115
118
  if budget_exhaustion_hot?
116
- n = [n, active_engine == :ollama ? 12 : 20].min
119
+ # Remote/overconfident thrash (this host): 12 was still burning full
120
+ # 12-step tool plans. Cap tighter when calibration also says overconfident.
121
+ base = active_engine == :ollama ? 8 : 12
122
+ base = [base, 8].min if cal[:overconfident]
123
+ n = [n, base].min
117
124
  end
118
125
  n
119
126
  rescue StandardError
@@ -261,17 +268,31 @@ module PWN
261
268
  # without leaking to the user.
262
269
  private_class_method def self.plan_first(opts = {})
263
270
  messages = opts[:messages]
271
+ # P17 — under budget_exhaustion_hot the plan must be ultra-short:
272
+ # red_team_plan is a nested agent loop and was the #1 amplifier of
273
+ # iteration-budget exhaustion on this host (together with CF).
274
+ hot = begin
275
+ budget_exhaustion_hot?
276
+ rescue StandardError
277
+ false
278
+ end
279
+ plan_prompt = if hot
280
+ 'Before acting: write AT MOST 3 numbered tool calls (name + key args) that finish the ask. Prefer fewer. LAST line: "p(success)=<0.0-1.0>". Reply ONLY with the plan + that line — no tools, no prose.'
281
+ else
282
+ 'Before acting: (1) list the exact tool calls (name + key args) you will make, in order; (2) on the LAST line write "p(success)=<0.0-1.0>". Reply ONLY with the numbered plan + that line — do NOT call any tool yet.'
283
+ end
264
284
  plan_msg = call_engine(
265
- messages: messages + [{ role: 'user',
266
- content: 'Before acting: (1) list the exact tool calls (name + key args) you will make, in order; (2) on the LAST line write "p(success)=<0.0-1.0>". Reply ONLY with the numbered plan + that line — do NOT call any tool yet.' }],
285
+ messages: messages + [{ role: 'user', content: plan_prompt }],
267
286
  tools: nil
268
287
  )
269
288
  return nil unless plan_msg && !plan_msg[:content].to_s.strip.empty?
270
289
 
271
290
  plan = plan_msg[:content].to_s.strip
272
291
  messages << { role: 'assistant', content: "PLAN:\n#{plan}" }
273
- # S4 — adversarial plan review grounded in THIS host's telemetry
274
- if defined?(Curriculum)
292
+ # S4 — adversarial plan review grounded in THIS host's telemetry.
293
+ # P17 — never fork red_team when budget fingerprints dominate: it is
294
+ # another mini agent loop and compounds iteration-budget exhaustion.
295
+ if defined?(Curriculum) && !hot
275
296
  rt = Curriculum.red_team_plan(request: opts[:request], plan: plan)
276
297
  messages << { role: 'user', content: rt } if rt
277
298
  end
@@ -563,7 +584,25 @@ module PWN
563
584
  # 3.1 — compact history on local so tool dumps don't fill num_ctx
564
585
  compact_history!(messages: messages) if local
565
586
 
566
- msg = call_engine(messages: messages, tools: tools)
587
+ # P17 on the final iteration, strip tools and demand a plain-text
588
+ # answer. Without this the model happily emits one more tool_calls
589
+ # batch, burns the last slot, and lands on budget_exhausted with
590
+ # nothing the user (or ORM) can use.
591
+ # P17 deepen — when budget_hot, force text-only on the LAST TWO
592
+ # iters so a final tool_calls batch cannot burn the terminal slot.
593
+ text_only_iters = budget_exhaustion_hot? ? 2 : 1
594
+ last_iter = (i >= max_iters - text_only_iters)
595
+ if last_iter
596
+ tag = i >= max_iters - 1 ? 'FINAL ITERATION' : 'PENULTIMATE — wrap up'
597
+ messages << {
598
+ role: 'user',
599
+ content: "[pwn-ai/p17] #{tag} — do NOT call any more tools. " \
600
+ 'Write the best answer you can from evidence already in this ' \
601
+ 'transcript. If blocked, say what failed and the next single step.'
602
+ }
603
+ end
604
+
605
+ msg = call_engine(messages: messages, tools: last_iter ? nil : tools)
567
606
  return '[pwn-ai] engine returned no message' if msg.nil?
568
607
 
569
608
  calls = Array(msg[:tool_calls])
@@ -613,7 +652,10 @@ module PWN
613
652
  # alt-persona branch, judge both, inject the winner. Real
614
653
  # advantage estimation; (loser, winner) → DPO preference.
615
654
  thresh = defined?(Mistakes) ? Mistakes::REPEAT_THRESHOLD : 3
616
- if count >= thresh && !escalated && defined?(Curriculum)
655
+ # P17 never fork counterfactual when budget fingerprints dominate:
656
+ # CF is another mini agent loop and is the #1 amplifier of
657
+ # iteration-budget exhaustion on this host.
658
+ if count >= thresh && !escalated && defined?(Curriculum) && !budget_exhaustion_hot?
617
659
  cf = (turn_fails["cf:#{fkey}"] += 1) == 1 ? Curriculum.counterfactual(request: request, name: name, args: args, error: tele[:err] || raw[0, 200], hint: hint) : nil
618
660
  hint = "#{hint}\n[pwn-ai/counterfactual] branch #{cf[:branch]} (score=#{cf[:score].round(2)}): #{cf[:content]}" if cf
619
661
  end
@@ -670,8 +712,29 @@ module PWN
670
712
  escalated = true
671
713
  end
672
714
 
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)
674
- '[pwn-ai] iteration budget exhausted'
715
+ # P17 exhaust path must still feed Learning so ORM/PRM/HER see the
716
+ # failure (previously we only Mistakes.record'd and returned a bare
717
+ # string — no session row, no judge, no hindsight).
718
+ final_msg = '[pwn-ai] iteration budget exhausted'
719
+ if defined?(Mistakes)
720
+ Mistakes.record(
721
+ tool: 'agent_loop',
722
+ error: 'iteration budget exhausted without a final answer',
723
+ session_id: session_id,
724
+ source: :loop,
725
+ shape: :budget_exhausted
726
+ )
727
+ end
728
+ append_session(session_id: session_id, role: 'assistant', content: final_msg)
729
+ if defined?(Learning) && should_auto_introspect?(local: local, turn_fails: turn_fails, iter: max_iters)
730
+ Learning.auto_introspect(
731
+ session_id: session_id,
732
+ request: request,
733
+ final: final_msg,
734
+ predicted: predicted
735
+ )
736
+ end
737
+ final_msg
675
738
  end
676
739
 
677
740
  # Author(s):: 0day Inc. <support@0dayinc.com>
@@ -166,7 +166,23 @@ module PWN
166
166
  " - #{r[:name]}: calls=#{r[:calls]} success=#{(adj * 100).round(1)}%#{tag}#{jtag} avg=#{r[:avg_duration]}s#{err}"
167
167
  end
168
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" : ''
169
- "#{warn_line}TOOL EFFECTIVENESS (#{scope}, adapt tool choice accordingly)\n#{lines.join("\n")}\n\n"
169
+ # P0 ops surface W1 generator_mix when diet is unhealthy so the
170
+ # online controller (and the model) prefer underfilled sources.
171
+ mix_line = ''
172
+ if defined?(Reward) && Reward.respond_to?(:generator_mix)
173
+ begin
174
+ m = Reward.generator_mix
175
+ unless m[:healthy]
176
+ mix_line = "W1 MIX: n=#{m[:n]} traj=#{m[:trajectory_fraction]} " \
177
+ "urgent=#{Array(m[:urgent]).join(',')} " \
178
+ "suppress=#{Array(m[:suppress]).join(',')} " \
179
+ "rec=#{m[:recommendation]}\n"
180
+ end
181
+ rescue StandardError
182
+ mix_line = ''
183
+ end
184
+ end
185
+ "#{warn_line}#{mix_line}TOOL EFFECTIVENESS (#{scope}, adapt tool choice accordingly)\n#{lines.join("\n")}\n\n"
170
186
  end
171
187
 
172
188
  # P4 helper — Registry.rank calls this so β·advantage is scaled down
@@ -239,30 +255,51 @@ module PWN
239
255
  0.0
240
256
  end
241
257
 
242
- # P18 — rolling mean step_reward for a tool (from Reward.prm annotations
243
- # folded into Metrics via record_step_reward). Range [-1, 1].
258
+ # P18/P2 — rolling mean step_reward advantage for a tool.
259
+ # Sample-efficiency gate: < PRM_MIN_N samples 0 (no rank noise).
260
+ # Shrinkage: adv *= min(1, n/PRM_FULL_N) so sparse signal cannot
261
+ # dominate UCB. Zero-variance windows (all +1 or all -1 from a
262
+ # single session) damp to 0.5×.
263
+ PRM_MIN_N = 5
264
+ PRM_FULL_N = 20
265
+
244
266
  public_class_method def self.prm_advantage(opts = {})
245
267
  data = load[:tools] || {}
246
268
  t = data[opts[:name].to_s.to_sym]
247
269
  return 0.0 unless t
248
270
 
249
271
  win = Array(t[:prm_window])
250
- return 0.0 if win.empty?
272
+ n = win.length
273
+ return 0.0 if n < PRM_MIN_N
251
274
 
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?)
275
+ mean = win.sum.to_f / n
276
+ globals = data.values.map { |v| Array(v[:prm_window]) }.select { |w| w.length >= PRM_MIN_N }
255
277
  gmean = if globals.empty?
256
278
  0.0
257
279
  else
258
280
  all = globals.flatten
259
281
  all.sum.to_f / all.length
260
282
  end
261
- (mean - gmean).round(3)
283
+ adv = mean - gmean
284
+ # shrinkage toward 0 until PRM_FULL_N
285
+ shrink = [n.to_f / PRM_FULL_N, 1.0].min
286
+ # variance damp: if all equal, halve influence
287
+ uniq = win.uniq
288
+ var_damp = uniq.length <= 1 ? 0.5 : 1.0
289
+ (adv * shrink * var_damp).round(3)
262
290
  rescue StandardError
263
291
  0.0
264
292
  end
265
293
 
294
+ public_class_method def self.prm_n(opts = {})
295
+ t = (load[:tools] || {})[opts[:name].to_s.to_sym]
296
+ return 0 unless t
297
+
298
+ Array(t[:prm_window]).length
299
+ rescue StandardError
300
+ 0
301
+ end
302
+
266
303
  # P18 — called by Reward.prm after session annotate so live routing
267
304
  # can bias toward tools that recently advanced goals (+1 step_reward).
268
305
  public_class_method def self.record_step_reward(opts = {})
@@ -290,10 +327,12 @@ module PWN
290
327
  return if name.empty?
291
328
 
292
329
  score = opts[:score].to_f.clamp(0.0, 1.0)
330
+ conf = opts.key?(:confidence) ? opts[:confidence].to_f.clamp(0.0, 1.0) : 0.7
293
331
  m = load
294
332
  m[:tools] ||= {}
295
333
  t = m[:tools][name.to_sym] ||= blank_bucket
296
334
  t[:judge_window] = (Array(t[:judge_window]) + [score]).last(40)
335
+ t[:judge_conf_window] = (Array(t[:judge_conf_window]) + [conf]).last(40)
297
336
  t[:judge_sum] = t[:judge_window].sum.to_f
298
337
  t[:judge_n] = t[:judge_window].length
299
338
  save(metrics: m)
@@ -302,6 +341,19 @@ module PWN
302
341
  nil
303
342
  end
304
343
 
344
+ # P1 — mean judge confidence for a tool (nil when no samples).
345
+ public_class_method def self.judge_confidence(opts = {})
346
+ t = (load[:tools] || {})[opts[:name].to_s.to_sym]
347
+ return nil unless t
348
+
349
+ win = Array(t[:judge_conf_window])
350
+ return nil if win.empty?
351
+
352
+ (win.sum.to_f / win.length).round(3)
353
+ rescue StandardError
354
+ nil
355
+ end
356
+
305
357
  # Mean judge score for a tool (nil when no ORM samples yet).
306
358
  public_class_method def self.judge_rate(opts = {})
307
359
  t = (load[:tools] || {})[opts[:name].to_s.to_sym]
@@ -331,8 +383,15 @@ module PWN
331
383
  distrust = 1.0 - proxy_trust
332
384
  jrate = judge_rate(name: name)
333
385
  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)
386
+ # P1 scale judge weight by judge confidence so a thin local
387
+ # heuristic ORM cannot fully replace proxy when distrust is high.
388
+ # effective_distrust = distrust * mean(confidence), floor 0.15 when
389
+ # we do have judge samples so the signal still moves the needle.
390
+ jconf = judge_confidence(name: name)
391
+ jconf = 0.7 if jconf.nil?
392
+ eff_d = (distrust * jconf).clamp(0.0, 1.0)
393
+ eff_d = [eff_d, 0.15].max if jconf >= 0.3
394
+ ((proxy * (1.0 - eff_d)) + (jrate * eff_d)).clamp(0.0, 1.0).round(3)
336
395
  else
337
396
  proxy.round(3)
338
397
  end
@@ -145,8 +145,27 @@ 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
+ # P18/P2 — PRM step_reward advantage closes R2 into the controller.
149
+ # Sample-efficiency: if fewer than 3 tools have prm_n≥PRM_MIN_N,
150
+ # drop delta to 0 so sparse PRM cannot inject rank variance.
151
+ # Otherwise scale delta by fleet coverage fraction.
152
+ prm_ready = 0
153
+ if defined?(Metrics) && Metrics.respond_to?(:prm_n)
154
+ prm_ready = entries.count do |e|
155
+ Metrics.prm_n(name: e.name).to_i >= begin
156
+ Metrics::PRM_MIN_N
157
+ rescue StandardError
158
+ 5
159
+ end
160
+ end
161
+ end
162
+ fleet = [entries.length, 1].max
163
+ coverage = prm_ready.to_f / fleet
164
+ delta = if prm_ready < 3
165
+ 0.0
166
+ else
167
+ 0.25 * trust * [coverage / 0.3, 1.0].min
168
+ end
150
169
  scored = entries.map do |e|
151
170
  hay = "#{e.name} #{e.toolset} #{e.schema[:description]} #{Array(e.schema.dig(:parameters, :properties)&.keys).join(' ')}".downcase
152
171
  sim = tokens.count { |t| hay.include?(t) }