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.
@@ -121,6 +121,40 @@ module PWN
121
121
  v = llm_judge(request: request, final: final, trace: trace)
122
122
  v ||= heuristic_judge(request: request, final: final, trace: trace)
123
123
 
124
+ # P1 — local/heuristic calibration: thin judges must not be treated
125
+ # as ground truth when proxy_distrust is already high. Two levers:
126
+ # (1) low :confidence so Metrics.effective_rate haircuts blend
127
+ # weight (distrust × confidence) instead of replacing proxy;
128
+ # (2) score-path caps only for decisive failure floors and for
129
+ # local no-trace highs (false "solved"). Do NOT pull known
130
+ # wrong (0.0 failure-language) toward 0.5, and do NOT deflate
131
+ # tool-backed heuristic scores that already cleared the bar —
132
+ # confidence handles that in the bandit blend.
133
+ eng = (PWN::Env.dig(:ai, :active) if defined?(PWN::Env)).to_s.downcase
134
+ local = eng == 'ollama' || eng.empty?
135
+ if v[:source].to_s == 'heuristic'
136
+ v[:confidence] = local ? 0.35 : 0.5
137
+ raw = v[:score].to_f
138
+ v[:score_raw] = raw
139
+ # preserve empty / failure-language / polite floors exactly (raw <= 0.15)
140
+ # and pass-through non-capped heuristics via the default arm.
141
+ if local && raw > 0.15 && trace.empty? && raw >= 0.6 && final.length < 400
142
+ v[:score] = [raw, 0.45].min
143
+ v[:rationale] = "#{v[:rationale]} | P1:local_no_trace_cap"
144
+ elsif local && raw > 0.15 && trace.length < 2 && raw >= 0.85
145
+ # thin-evidence local highs: mild shrink toward 0.5
146
+ v[:score] = (0.5 + ((raw - 0.5) * 0.7)).round(3).clamp(0.0, 1.0)
147
+ else
148
+ v[:score] = raw
149
+ end
150
+ v[:verdict] = if v[:score] >= 0.6 then :solved
151
+ elsif v[:score] >= 0.3 then :partial
152
+ else :wrong
153
+ end
154
+ else
155
+ v[:confidence] ||= 0.85
156
+ end
157
+
124
158
  ground = verify_as_reward(final: final)
125
159
  unless ground.nil?
126
160
  # Ground-truth override: a browser-refuted claim caps score at
@@ -129,13 +163,17 @@ module PWN
129
163
  v[:score] = [v[:score], 0.2].min if ground[:verdict] == :refuted
130
164
  v[:score] = [v[:score], 0.6].max if ground[:verdict] == :confirmed
131
165
  v[:grounded] = ground
166
+ v[:confidence] = [v[:confidence].to_f, ground[:confidence].to_f].max if ground[:confidence]
132
167
  end
133
168
 
134
169
  v[:success] = v[:score] >= 0.6
135
- record_sentinel(proxy: opts[:proxy_ok], judge: v[:score]) if commit
170
+ v[:engine] = eng
171
+ # P1 — sentinel stores confidence so distrust math can haircut
172
+ # heuristic-heavy windows differently from LLM ORM windows.
173
+ record_sentinel(proxy: opts[:proxy_ok], judge: v[:score], confidence: v[:confidence]) if commit
136
174
  v
137
175
  rescue StandardError => e
138
- { score: 0.5, verdict: :unknown, rationale: "judge error: #{e.class}", success: !final.strip.empty?, error: e.message }
176
+ { score: 0.5, verdict: :unknown, rationale: "judge error: #{e.class}", success: !final.strip.empty?, error: e.message, confidence: 0.2, source: :error }
139
177
  end
140
178
 
141
179
  # ----------------------------------------------------------------
@@ -429,6 +467,14 @@ module PWN
429
467
  claim = final[Learning::CLAIM_RX] if defined?(Learning)
430
468
  return nil if claim.to_s.empty?
431
469
 
470
+ # P26 — drop metric crumbs ("cap 0.2") that match loose patterns
471
+ if defined?(Learning) && Learning.respond_to?(:checkable_claim?, true)
472
+ return nil unless Learning.send(:checkable_claim?, claim: claim)
473
+ elsif claim.match?(/\A(?:cap|share|proxy|judge|success|only|now|gap|score|rate)\b/i) ||
474
+ (claim.match?(/\d+\.\d+/) && !claim.match?(/\d+\.\d+\.\d+|CVE-/i))
475
+ return nil
476
+ end
477
+
432
478
  # 1.5 — sampled E3: always when flag true; never when false;
433
479
  # nil/auto → always on frontier, ~10% on local when CLAIM_RX hits.
434
480
  flag = agent_flag(key: :verify_as_reward, default: nil)
@@ -470,6 +516,19 @@ module PWN
470
516
  WRITE_SOURCE_CAP = 0.40
471
517
  WRITE_SOURCE_WINDOW = 100
472
518
 
519
+ # P0 — target online generator mix for W1. Gates alone cannot fill
520
+ # an empty promote; the controller must *prefer underfilled* sources
521
+ # (counterfactual / critic / curriculum / user_correction) when
522
+ # resolve already dominates. Shares are soft targets, not hard caps
523
+ # (hard cap remains WRITE_SOURCE_CAP). Trajectory-only still applies.
524
+ TARGET_SOURCE_MIX = {
525
+ 'mistakes_resolve' => 0.30,
526
+ 'curriculum' => 0.25,
527
+ 'counterfactual' => 0.20,
528
+ 'critic' => 0.15,
529
+ 'user_correction' => 0.10
530
+ }.freeze
531
+
473
532
  public_class_method def self.record_preference(opts = {})
474
533
  prompt = opts[:prompt].to_s
475
534
  rejected = opts[:rejected].to_s
@@ -525,20 +584,112 @@ module PWN
525
584
  public_class_method def self.write_source_quota(opts = {})
526
585
  source = opts[:source].to_s
527
586
  recent = preferences(limit: WRITE_SOURCE_WINDOW)
528
- return { over_cap: false, share: 0.0, n: 0, window: recent.length } if recent.length < 10
587
+ return { over_cap: false, share: 0.0, n: 0, window: recent.length, underfilled: true } if recent.length < 10
529
588
 
530
589
  n = recent.count { |r| r[:source].to_s == source }
531
590
  share = n.to_f / recent.length
591
+ target = TARGET_SOURCE_MIX[source]
532
592
  {
533
593
  over_cap: share > WRITE_SOURCE_CAP,
534
594
  share: share.round(3),
535
595
  n: n,
536
596
  window: recent.length,
537
597
  source: source,
538
- cap: WRITE_SOURCE_CAP
598
+ cap: WRITE_SOURCE_CAP,
599
+ target: target,
600
+ underfilled: target ? share < (target * 0.5) : share < 0.05,
601
+ deficit: target ? (target - share).round(3) : nil
602
+ }
603
+ rescue StandardError
604
+ { over_cap: false, share: 0.0, underfilled: true }
605
+ end
606
+
607
+ # P0 — online generator mix report + urgency flags. Controllers
608
+ # (auto_introspect, practice, counterfactual gate) consult this so
609
+ # underfilled sources get scheduling priority while over-cap
610
+ # resolve stops flooding. Returns {by_source, trajectory_fraction,
611
+ # urgent:[], suppress:[], healthy:}.
612
+ public_class_method def self.generator_mix(opts = {})
613
+ limit = opts[:limit] || WRITE_SOURCE_WINDOW
614
+ rows = preferences(limit: limit)
615
+ usable = rows.select { |r| usable_preference?(row: r) }
616
+ by = Hash.new(0)
617
+ usable.each { |r| by[r[:source].to_s] += 1 }
618
+ n = usable.length
619
+ shares = {}
620
+ TARGET_SOURCE_MIX.each_key { |k| shares[k] = n.zero? ? 0.0 : (by[k].to_f / n).round(3) }
621
+ by.each_key { |k| shares[k] ||= (by[k].to_f / n).round(3) }
622
+
623
+ traj_n = usable.count { |r| TRAJECTORY_SHAPES.include?(r[:shape].to_s) }
624
+ traj_f = n.zero? ? 0.0 : (traj_n.to_f / n).round(3)
625
+
626
+ urgent = []
627
+ suppress = []
628
+ TARGET_SOURCE_MIX.each do |src, target|
629
+ sh = shares[src].to_f
630
+ urgent << src if sh < (target * 0.5) && n >= 5
631
+ suppress << src if sh > WRITE_SOURCE_CAP && n >= 10
632
+ end
633
+ suppress << 'mistakes_resolve' if shares['mistakes_resolve'].to_f > WRITE_SOURCE_CAP && n >= 10 && !suppress.include?('mistakes_resolve')
634
+
635
+ healthy = urgent.empty? && suppress.empty? && traj_f >= 0.5 && n >= 10
636
+ {
637
+ n: n,
638
+ raw_n: rows.length,
639
+ by_source: by,
640
+ shares: shares,
641
+ targets: TARGET_SOURCE_MIX,
642
+ trajectory_fraction: traj_f,
643
+ urgent: urgent.uniq,
644
+ suppress: suppress.uniq,
645
+ healthy: healthy,
646
+ recommendation: if healthy
647
+ 'mix_ok'
648
+ elsif n < 10
649
+ 'need_more_pairs'
650
+ elsif traj_f < 0.5
651
+ 'need_trajectory_shape'
652
+ elsif urgent.any?
653
+ "boost:#{urgent.join(',')}"
654
+ else
655
+ "suppress:#{suppress.join(',')}"
656
+ end
539
657
  }
658
+ rescue StandardError => e
659
+ {
660
+ n: 0, healthy: false, error: "#{e.class}: #{e.message}",
661
+ urgent: %w[curriculum counterfactual critic user_correction],
662
+ suppress: []
663
+ }
664
+ end
665
+
666
+ # P0 ops — infer trajectory shape for legacy ledger rows that predate
667
+ # P21/P25 shape tags. Used by scrub_preferences rewrite so generator_mix
668
+ # trajectory_fraction reflects content, not missing keys.
669
+ public_class_method def self.infer_shape(opts = {})
670
+ r = opts.is_a?(Hash) && opts.key?(:row) ? opts[:row] : opts
671
+ r = r.transform_keys(&:to_sym) if r.respond_to?(:transform_keys)
672
+ existing = r[:shape].to_s
673
+ return existing if TRAJECTORY_SHAPES.include?(existing) || existing == 'fix_prose'
674
+
675
+ chosen = r[:chosen].to_s
676
+ source = r[:source].to_s
677
+ # tool-call / trace markers → winning_trace
678
+ if chosen.match?(/\b(shell|pwn_eval|memory_|sessions_|reward_|curriculum_|extro_|mistakes_)\b/i) &&
679
+ (chosen.include?('→') || chosen.include?('tool_call') || chosen.include?('"name"') ||
680
+ chosen.lines.count { |l| l.strip.start_with?('{') || l.include?('arguments') } >= 1)
681
+ return 'winning_trace'
682
+ end
683
+ # long revised answer from critic / user / CF → revised_answer
684
+ return 'revised_answer' if chosen.length >= 200 && %w[critic user_correction counterfactual curriculum].include?(source)
685
+
686
+ # counterfactual real dispatch tag in meta
687
+ meta = r[:meta].is_a?(Hash) ? r[:meta] : {}
688
+ return 'real_dispatch' if meta[:mode].to_s == 'real_dispatch' || meta['mode'].to_s == 'real_dispatch'
689
+
690
+ existing.empty? ? nil : existing
540
691
  rescue StandardError
541
- { over_cap: false, share: 0.0 }
692
+ nil
542
693
  end
543
694
 
544
695
  # P15 — keep only usable preference pairs for balance/export/promote.
@@ -586,6 +737,11 @@ module PWN
586
737
  next
587
738
  end
588
739
  if usable_preference?(row: r)
740
+ # P0 ops — backfill shape so trajectory_fraction is meaningful
741
+ if r[:shape].to_s.empty?
742
+ inferred = infer_shape(row: r)
743
+ r = r.merge(shape: inferred) if inferred
744
+ end
589
745
  kept << r
590
746
  else
591
747
  why = if r[:chosen].to_s.match?(/\A\s*CORRECTION:\s*/i)
@@ -640,6 +796,11 @@ module PWN
640
796
  traj_n = rows.count { |r| TRAJECTORY_SHAPES.include?(r[:shape].to_s) }
641
797
  traj_frac = total.zero? ? 0.0 : (traj_n.to_f / total).round(3)
642
798
  monoculture = total.positive? && (by.values.max.to_f / total) > 0.7
799
+ mix = begin
800
+ generator_mix(limit: limit)
801
+ rescue StandardError
802
+ nil
803
+ end
643
804
  {
644
805
  total: before,
645
806
  kept: total,
@@ -651,12 +812,15 @@ module PWN
651
812
  by_shape_fraction: shape_frac,
652
813
  trajectory_fraction: traj_frac,
653
814
  monoculture: monoculture,
815
+ generator_mix: mix,
654
816
  advice: if total < 12
655
817
  'W1 thin: need more trajectory-shaped pairs before LoRA promote.'
656
818
  elsif monoculture
657
819
  'W1 monoculture: run Reward.scrub_preferences; enable :counterfactual/:critic; stop resolve-prose flood.'
658
820
  elsif traj_frac < 0.30
659
821
  'W1 geometry weak: <30% trajectory-shaped chosen sides — DPO would teach commentary.'
822
+ elsif mix && !mix[:healthy]
823
+ "W1 generator mix: #{mix[:recommendation]}"
660
824
  else
661
825
  'W1 source mix OK for gated export'
662
826
  end
@@ -902,6 +1066,8 @@ module PWN
902
1066
  # write must not poison rolling means forever.
903
1067
  judge = opts[:judge].to_f.clamp(0.0, 1.0)
904
1068
  entry = { judge: judge, at: Time.now.utc.iso8601 }
1069
+ # P1 — optional per-sample confidence (heuristic < LLM ORM)
1070
+ entry[:confidence] = opts[:confidence].to_f.clamp(0.0, 1.0) unless opts[:confidence].nil?
905
1071
  # 1.3 — only roll proxy into the window when the caller actually
906
1072
  # supplied a R4-aligned proxy_ok. Pre-ORM boolean noise no longer
907
1073
  # dilutes gap_proxy_judge. Proxy is ALWAYS 0.0 or 1.0 when present.
@@ -144,3 +144,26 @@ PWN::AI::Agent::Registry.register(
144
144
  check: -> { defined?(PWN::AI::Agent::Curriculum) && PWN::AI::Agent::Curriculum.respond_to?(:preference_balance) },
145
145
  handler: ->(args) { PWN::AI::Agent::Curriculum.preference_balance(limit: args[:limit]) }
146
146
  )
147
+
148
+ PWN::AI::Agent::Registry.register(
149
+ name: 'curriculum_practice_kpi',
150
+ toolset: 'curriculum',
151
+ schema: {
152
+ name: 'curriculum_practice_kpi',
153
+ description: 'P1 — Outer curriculum KPI: unresolved [REPEATING] count + week-over-week trend (improving|flat|regressing). Snapshots land in ~/.pwn/curriculum_kpi.jsonl from practice().',
154
+ parameters: {
155
+ type: 'object',
156
+ properties: {
157
+ limit: { type: 'integer', description: 'Trend window snapshots (default 14)' }
158
+ },
159
+ required: []
160
+ }
161
+ },
162
+ check: -> { defined?(PWN::AI::Agent::Curriculum) && PWN::AI::Agent::Curriculum.respond_to?(:repeating_trend) },
163
+ handler: lambda { |args|
164
+ {
165
+ trend: PWN::AI::Agent::Curriculum.repeating_trend(limit: args[:limit]),
166
+ snapshot: PWN::AI::Agent::Curriculum.practice_kpi(results: [])
167
+ }
168
+ }
169
+ )
@@ -192,3 +192,23 @@ PWN::AI::Agent::Registry.register(
192
192
  )
193
193
  }
194
194
  )
195
+
196
+ PWN::AI::Agent::Registry.register(
197
+ name: 'reward_generator_mix',
198
+ toolset: 'reward',
199
+ schema: {
200
+ name: 'reward_generator_mix',
201
+ description: 'P0 — Online W1 generator mix vs TARGET_SOURCE_MIX. Returns urgent/suppress sources, trajectory_fraction, recommendation (boost:… / suppress:…). Controllers use this to schedule counterfactual/critic/curriculum over resolve flood.',
202
+ parameters: {
203
+ type: 'object',
204
+ properties: {
205
+ limit: { type: 'integer', description: 'Window size (default WRITE_SOURCE_WINDOW)' }
206
+ },
207
+ required: []
208
+ }
209
+ },
210
+ check: -> { defined?(PWN::AI::Agent::Reward) && PWN::AI::Agent::Reward.respond_to?(:generator_mix) },
211
+ handler: lambda { |args|
212
+ PWN::AI::Agent::Reward.generator_mix(limit: args[:limit])
213
+ }
214
+ )
@@ -39,19 +39,42 @@ PWN::AI::Agent::Registry.register(
39
39
  # INTENTIONAL: this IS the pwn-ai → PWN bridge
40
40
  # As YTCracker says, "It ain't a bug, it's a featcha."
41
41
  # https://www.youtube.com/watch?v=2nALqqSqdDw
42
- # val = eval(code, TOPLEVEL_BINDING, '(pwn_eval)')
42
+ #
43
+ # File label '(pwn_eval)' (not __FILE__) so SyntaxError / NameError
44
+ # messages cite the *payload* line, not ruby_eval.rb:49 glued to source
45
+ # (e.g. "ruby_eval:49results = probes.map do |R|").
46
+ # Rescue ScriptError (SyntaxError, LoadError, NotImplementedError) as
47
+ # well as StandardError — Dispatch only catches StandardError, so an
48
+ # unrescued SyntaxError previously escaped the tool loop. Returning a
49
+ # structured error lets the model self-heal (fix code and retry).
50
+ # rubocop:disable Style/EvalWithLocation -- intentional file label
51
+ # '(pwn_eval)' so SyntaxError/NameError messages cite the payload, not
52
+ # this library line (specs + model self-heal depend on that string).
43
53
  proc = eval(
44
- "proc { #{code} }",
54
+ "proc { #{code}\n}",
45
55
  TOPLEVEL_BINDING,
46
- __FILE__,
47
- __LINE__ - 3
56
+ '(pwn_eval)',
57
+ 1
48
58
  )
59
+ # rubocop:enable Style/EvalWithLocation
49
60
  val = proc.call
50
61
  # rubocop:enable Security/Eval
51
62
  # rubocop:enable Style/DocumentDynamicEvalDefinition
52
63
  { stdout: buf.string, value: val.inspect }
53
-
54
- # TODO: A rescue here may enable self-healing of the agent if the model emits code that raises an exception. The model could then be prompted to fix the code and try again.
64
+ rescue ScriptError, StandardError => e
65
+ {
66
+ stdout: buf.string,
67
+ error: "#{e.class}: #{e.message}",
68
+ backtrace: Array(e.backtrace).first(5),
69
+ # Hint common payload footguns the model keeps emitting
70
+ hint: (
71
+ if e.is_a?(SyntaxError) && e.message.match?(/formal argument cannot be a constant/)
72
+ 'Block parameters must be local variables (lowercase), e.g. |r| not |R|. Also close every do/end.'
73
+ elsif e.is_a?(SyntaxError)
74
+ 'Payload failed to parse. Check matching do/end, braces, and that no line-number prefix was glued onto the code.'
75
+ end
76
+ )
77
+ }
55
78
  ensure
56
79
  $stdout = old_stdout
57
80
  end
data/lib/pwn/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PWN
4
- VERSION = '0.5.647'
4
+ VERSION = '0.5.650'
5
5
  end