pwn 0.5.643 → 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.
@@ -297,6 +297,58 @@ module PWN
297
297
  { cleared: true, path: SENTINEL_FILE }
298
298
  end
299
299
 
300
+ # P10 — backfill the R3 ring from Learning outcomes so offline/local
301
+ # hosts reach SENTINEL_WINDOW without waiting for live remote
302
+ # introspect. Only fills empty slots; never flushes a warm window.
303
+ # Called by Curriculum.offline_judge and safe to cron.
304
+ public_class_method def self.warm_sentinel(opts = {})
305
+ s = normalize_sentinel(raw: load_sentinel)
306
+ have = Array(s[:window]).length
307
+ return { added: 0, samples: have, status: :full, proxy_distrust: proxy_distrust } if have >= SENTINEL_WINDOW
308
+ return { added: 0, samples: have, status: :no_learning, proxy_distrust: proxy_distrust } unless defined?(Learning)
309
+
310
+ need = SENTINEL_WINDOW - have
311
+ limit = (opts[:limit] || [need * 4, 200].max).to_i
312
+ # Prefer scored rows; fall back to success-boolean so local hosts still warm.
313
+ rows = Learning.outcomes(limit: limit)
314
+ scored, unscored = rows.partition { |r| !r[:score].nil? }
315
+ ordered = scored.reverse + unscored.reverse
316
+ added = 0
317
+ ordered.each do |r|
318
+ break if added >= need
319
+
320
+ judge = if r[:score]
321
+ r[:score].to_f.clamp(0.0, 1.0)
322
+ else
323
+ case r[:success]
324
+ when true, 'true' then 0.75
325
+ when 'soft' then 0.55
326
+ when false, 'false' then 0.25
327
+ else 0.5
328
+ end
329
+ end
330
+ proxy = case r[:success]
331
+ when true, 'true' then true
332
+ when false, 'false', 'soft' then false
333
+ else judge >= 0.6
334
+ end
335
+ record_sentinel(proxy: proxy, judge: judge)
336
+ added += 1
337
+ end
338
+ final_n = Array(load_sentinel[:window]).length
339
+ # Recompute distrust once window is full so controllers can engage.
340
+ snap = final_n >= SENTINEL_WINDOW ? sentinel : { samples: final_n, status: :insufficient }
341
+ {
342
+ added: added,
343
+ samples: final_n,
344
+ status: (final_n >= SENTINEL_WINDOW ? :warmed_full : :warmed_partial),
345
+ proxy_distrust: proxy_distrust,
346
+ sentinel: snap.is_a?(Hash) ? snap.slice(:samples, :status, :reward_hacked, :proxy_distrust, :proxy, :judge) : nil
347
+ }
348
+ rescue StandardError => e
349
+ { added: 0, error: "#{e.class}: #{e.message}" }
350
+ end
351
+
300
352
  # ----------------------------------------------------------------
301
353
  # R4 — Structured tool-result classifier
302
354
  # ----------------------------------------------------------------
@@ -408,6 +460,16 @@ module PWN
408
460
  # source: 'optional - :user_correction | :mistakes_resolve | :counterfactual | :critic'
409
461
  # )
410
462
 
463
+ # Trajectory-shaped chosen sides that may land DPO without prose flood.
464
+ TRAJECTORY_SHAPES = %w[winning_trace revised_answer real_dispatch].freeze
465
+
466
+ # P9 — write-time source quota (not only export). Prefer diverse online
467
+ # generators over resolve-prose flood. Window is last WRITE_SOURCE_WINDOW
468
+ # pairs; a source already above WRITE_SOURCE_CAP is refused unless
469
+ # force: true (user_correction always forces).
470
+ WRITE_SOURCE_CAP = 0.40
471
+ WRITE_SOURCE_WINDOW = 100
472
+
411
473
  public_class_method def self.record_preference(opts = {})
412
474
  prompt = opts[:prompt].to_s
413
475
  rejected = opts[:rejected].to_s
@@ -415,20 +477,194 @@ module PWN
415
477
  return nil if prompt.strip.empty? || chosen.strip.empty? || rejected.strip.empty?
416
478
  return nil if chosen.strip == rejected.strip
417
479
 
480
+ # Reject weak pair geometry: CORRECTION: flaw-prose is not a trajectory.
481
+ return { skipped: :weak_pair_geometry, reason: 'chosen looks like flaw prose, not a revised answer/trace' } if chosen.match?(/\A\s*CORRECTION:\s*/i) && chosen.length < 400 && !opts[:force]
482
+
483
+ source = (opts[:source] || :unknown).to_s
484
+ shape = opts[:shape].to_s
485
+ # P25 — require trajectory shape at write time unless force / user_correction.
486
+ # Stops resolve-prose flood from ever landing in the ledger; export scrub
487
+ # is defense-in-depth, not the primary gate.
488
+ traj = TRAJECTORY_SHAPES.include?(shape)
489
+ # P25 — non-trajectory prose never lands (export scrub is defense-in-depth).
490
+ # user_correction and explicit force: still allowed for human / migration paths.
491
+ unless traj || opts[:force] || source == 'user_correction'
492
+ return {
493
+ skipped: :non_trajectory_shape,
494
+ reason: "shape=#{shape.inspect} not in #{TRAJECTORY_SHAPES.join(',')}; pass force:true or a trajectory shape",
495
+ source: source
496
+ }
497
+ end
498
+ # P9 — write-time source quota still applies to trajectory pairs.
499
+ # P25 made every auto-written row trajectory-shaped; if traj also
500
+ # bypassed the quota, resolve monoculture would return via winning_trace
501
+ # flood. Only user_correction and explicit force:true skip the cap.
502
+ bypass_quota = opts[:force] || source == 'user_correction'
503
+ unless bypass_quota
504
+ quota = write_source_quota(source: source)
505
+ return quota.merge(skipped: :source_quota) if quota[:over_cap]
506
+ end
507
+
418
508
  entry = {
419
509
  id: Digest::SHA256.hexdigest("#{prompt}|#{rejected}|#{chosen}")[0, 12],
420
510
  prompt: prompt[0, 4_000],
421
511
  rejected: rejected[0, 4_000],
422
512
  chosen: chosen[0, 4_000],
423
- source: (opts[:source] || :unknown).to_s,
513
+ source: source,
424
514
  engine: (PWN::Env.dig(:ai, :active) if defined?(PWN::Env)).to_s,
425
515
  timestamp: Time.now.utc.iso8601
426
516
  }
517
+ entry[:meta] = opts[:meta] if opts[:meta].is_a?(Hash)
518
+ entry[:shape] = opts[:shape].to_s if opts[:shape]
427
519
  FileUtils.mkdir_p(File.dirname(PREFERENCES_FILE))
428
520
  File.open(PREFERENCES_FILE, 'a') { |f| f.puts(JSON.generate(entry)) }
429
521
  entry
430
522
  end
431
523
 
524
+ # Share of `source` among the newest WRITE_SOURCE_WINDOW prefs.
525
+ public_class_method def self.write_source_quota(opts = {})
526
+ source = opts[:source].to_s
527
+ recent = preferences(limit: WRITE_SOURCE_WINDOW)
528
+ return { over_cap: false, share: 0.0, n: 0, window: recent.length } if recent.length < 10
529
+
530
+ n = recent.count { |r| r[:source].to_s == source }
531
+ share = n.to_f / recent.length
532
+ {
533
+ over_cap: share > WRITE_SOURCE_CAP,
534
+ share: share.round(3),
535
+ n: n,
536
+ window: recent.length,
537
+ source: source,
538
+ cap: WRITE_SOURCE_CAP
539
+ }
540
+ rescue StandardError
541
+ { over_cap: false, share: 0.0 }
542
+ end
543
+
544
+ # P15 — keep only usable preference pairs for balance/export/promote.
545
+ # Drops CORRECTION-only chosen, resolve rows without trajectory shape,
546
+ # and chosen≪rejected unless shape is a known trajectory form.
547
+ public_class_method def self.usable_preference?(opts = {})
548
+ r = opts.is_a?(Hash) && opts.key?(:row) ? opts[:row] : opts
549
+ r = r.transform_keys(&:to_sym) if r.respond_to?(:transform_keys)
550
+ chosen = r[:chosen].to_s
551
+ rejected = r[:rejected].to_s
552
+ shape = r[:shape].to_s
553
+ source = r[:source].to_s
554
+ return false if chosen.strip.empty? || rejected.strip.empty?
555
+ return false if chosen.strip == rejected.strip
556
+ return false if chosen.match?(/\A\s*CORRECTION:\s*/i) && chosen.length < 400
557
+ return false if shape == 'fix_prose'
558
+ # P25 — resolve rows must be trajectory-shaped to count as usable
559
+ return false if source == 'mistakes_resolve' && !TRAJECTORY_SHAPES.include?(shape)
560
+
561
+ # chosen ≪ rejected without trajectory shape → commentary, not policy
562
+ unless TRAJECTORY_SHAPES.include?(shape)
563
+ return false if rejected.length >= 200 && chosen.length < (rejected.length * 0.25) && chosen.length < 200
564
+ return false if rejected.length >= 400 && chosen.length < 120
565
+ end
566
+ true
567
+ rescue StandardError
568
+ false
569
+ end
570
+
571
+ # P15 — one-shot ledger hygiene. Filters in place (rewrite jsonl) or
572
+ # report-only. Returns {before:, after:, dropped:, by_reason:, path:}.
573
+ public_class_method def self.scrub_preferences(opts = {})
574
+ dry = opts.key?(:dry_run) ? opts[:dry_run] : false
575
+ path = PREFERENCES_FILE
576
+ return { before: 0, after: 0, dropped: 0, dry_run: dry, path: path } unless File.exist?(path)
577
+
578
+ raw = File.readlines(path)
579
+ kept = []
580
+ reasons = Hash.new(0)
581
+ raw.each do |line|
582
+ begin
583
+ r = JSON.parse(line, symbolize_names: true)
584
+ rescue StandardError
585
+ reasons[:parse_error] += 1
586
+ next
587
+ end
588
+ if usable_preference?(row: r)
589
+ kept << r
590
+ else
591
+ why = if r[:chosen].to_s.match?(/\A\s*CORRECTION:\s*/i)
592
+ :correction_prose
593
+ elsif r[:shape].to_s == 'fix_prose'
594
+ :fix_prose
595
+ elsif r[:chosen].to_s.length < (r[:rejected].to_s.length * 0.25)
596
+ :chosen_too_short
597
+ else
598
+ :weak_geometry
599
+ end
600
+ reasons[why] += 1
601
+ end
602
+ end
603
+ unless dry
604
+ bak = "#{path}.bak-p15-#{Time.now.utc.strftime('%Y%m%d%H%M%S')}"
605
+ FileUtils.cp(path, bak)
606
+ File.open(path, 'w') { |f| kept.each { |r| f.puts(JSON.generate(r)) } }
607
+ end
608
+ {
609
+ before: raw.length,
610
+ after: kept.length,
611
+ dropped: raw.length - kept.length,
612
+ by_reason: reasons,
613
+ dry_run: dry,
614
+ path: path,
615
+ backup: dry ? nil : bak
616
+ }
617
+ rescue StandardError => e
618
+ { error: "#{e.class}: #{e.message}" }
619
+ end
620
+
621
+ # P15/P5 — geometry-aware source mix. scrub:true uses usable_preference?
622
+ # so operators see the post-hygiene diet (what export_dpo will train on).
623
+ public_class_method def self.preference_balance(opts = {})
624
+ limit = opts[:limit] || 10_000
625
+ scrub = opts.key?(:scrub) ? opts[:scrub] : false
626
+ rows = preferences(limit: limit)
627
+ before = rows.length
628
+ rows = rows.select { |r| usable_preference?(row: r) } if scrub
629
+ by = Hash.new(0)
630
+ by_shape = Hash.new(0)
631
+ rows.each do |r|
632
+ by[r[:source].to_s] += 1
633
+ sh = r[:shape].to_s
634
+ sh = 'unspecified' if sh.empty?
635
+ by_shape[sh] += 1
636
+ end
637
+ total = rows.length
638
+ frac = by.transform_values { |n| total.zero? ? 0.0 : (n.to_f / total).round(3) }
639
+ shape_frac = by_shape.transform_values { |n| total.zero? ? 0.0 : (n.to_f / total).round(3) }
640
+ traj_n = rows.count { |r| TRAJECTORY_SHAPES.include?(r[:shape].to_s) }
641
+ traj_frac = total.zero? ? 0.0 : (traj_n.to_f / total).round(3)
642
+ monoculture = total.positive? && (by.values.max.to_f / total) > 0.7
643
+ {
644
+ total: before,
645
+ kept: total,
646
+ scrubbed: scrub,
647
+ dropped: before - total,
648
+ by_source: by,
649
+ fractions: frac,
650
+ by_shape: by_shape,
651
+ by_shape_fraction: shape_frac,
652
+ trajectory_fraction: traj_frac,
653
+ monoculture: monoculture,
654
+ advice: if total < 12
655
+ 'W1 thin: need more trajectory-shaped pairs before LoRA promote.'
656
+ elsif monoculture
657
+ 'W1 monoculture: run Reward.scrub_preferences; enable :counterfactual/:critic; stop resolve-prose flood.'
658
+ elsif traj_frac < 0.30
659
+ 'W1 geometry weak: <30% trajectory-shaped chosen sides — DPO would teach commentary.'
660
+ else
661
+ 'W1 source mix OK for gated export'
662
+ end
663
+ }
664
+ rescue StandardError => e
665
+ { error: "#{e.class}: #{e.message}" }
666
+ end
667
+
432
668
  # Supported Method Parameters::
433
669
  # rows = PWN::AI::Agent::Reward.preferences(limit: 500, source: nil)
434
670
 
@@ -463,6 +699,15 @@ module PWN
463
699
  FileUtils.mkdir_p(DPO_DIR)
464
700
  out = opts[:out] || File.join(DPO_DIR, "pwn-dpo-#{Time.now.utc.strftime('%Y%m%d')}.jsonl")
465
701
  rows = preferences(limit: 100_000)
702
+ # P15 — drop weak geometry before source-cap so resolve prose cannot
703
+ # dominate the kept set after balance. opt-out with scrub: false.
704
+ scrub = opts.key?(:scrub) ? opts[:scrub] : true
705
+ geometry_dropped = 0
706
+ if scrub
707
+ usable = rows.select { |r| usable_preference?(row: r) }
708
+ geometry_dropped = rows.length - usable.length
709
+ rows = usable
710
+ end
466
711
  # P5 — downsample so no single source exceeds DPO_SOURCE_CAP of the export.
467
712
  # opt-out with balance: false (raw dump for diagnostics).
468
713
  balance = opts.key?(:balance) ? opts[:balance] : true
@@ -484,8 +729,14 @@ module PWN
484
729
  by_src = selected.group_by { |r| r[:source].to_s }.transform_values(&:length)
485
730
  {
486
731
  path: out, format: fmt, pairs: selected.length, bytes: File.size(out),
487
- balanced: balance, dropped: dropped, by_source: by_src,
488
- source_cap: balance ? (opts[:source_cap] || DPO_SOURCE_CAP).to_f : nil
732
+ balanced: balance, dropped: dropped, geometry_dropped: geometry_dropped,
733
+ scrubbed: scrub, by_source: by_src,
734
+ source_cap: balance ? (opts[:source_cap] || DPO_SOURCE_CAP).to_f : nil,
735
+ preference_balance: begin
736
+ preference_balance(limit: 10_000, scrub: true)
737
+ rescue StandardError
738
+ nil
739
+ end
489
740
  }
490
741
  end
491
742
 
@@ -615,7 +866,11 @@ module PWN
615
866
  j = JSON.parse(l, symbolize_names: true)
616
867
  if j[:role].to_s == 'tool'
617
868
  ti += 1
618
- j[:step_reward] = rewards[ti] if rewards[ti]
869
+ if rewards[ti]
870
+ j[:step_reward] = rewards[ti]
871
+ # P18 — fold step_reward into Metrics so Registry.rank can bias
872
+ fold_step_reward_to_metrics(content: j[:content], reward: rewards[ti])
873
+ end
619
874
  end
620
875
  "#{JSON.generate(j)}\n"
621
876
  rescue StandardError
@@ -626,6 +881,21 @@ module PWN
626
881
  nil
627
882
  end
628
883
 
884
+ # Extract tool name from "name → …" session content and record PRM.
885
+ private_class_method def self.fold_step_reward_to_metrics(opts = {})
886
+ return unless defined?(Metrics) && Metrics.respond_to?(:record_step_reward)
887
+
888
+ content = opts[:content].to_s
889
+ name = content[/\A([a-z_][a-z0-9_]*)\s*→/i, 1] ||
890
+ content[/\A([a-z_][a-z0-9_]*)\s*->/i, 1] ||
891
+ content[/\A([a-z_][a-z0-9_]*)/, 1]
892
+ return if name.to_s.empty?
893
+
894
+ Metrics.record_step_reward(name: name, reward: opts[:reward])
895
+ rescue StandardError
896
+ nil
897
+ end
898
+
629
899
  private_class_method def self.record_sentinel(opts = {})
630
900
  s = normalize_sentinel(raw: load_sentinel)
631
901
  # Clamp judge to [0,1] — LLM/heuristic should already, but a bad
@@ -872,13 +1142,17 @@ module PWN
872
1142
  PWN::AI::Agent::Reward.prm(request: req, session_id: sid) # R2 PRM → per-step credit
873
1143
  PWN::AI::Agent::Reward.sentinel # R3 reward-hacking detector
874
1144
  PWN::AI::Agent::Reward.reset_sentinel # wipe corrupt window + distrust
1145
+ PWN::AI::Agent::Reward.warm_sentinel # P10 fill R3 window from Learning outcomes
875
1146
  PWN::AI::Agent::Reward.semantic_ok(name: 'shell', raw: json, args: args) # R4 kills phantom exit≠0 mistakes
876
1147
 
877
1148
  # Tier 5 — preference pairs → DPO
878
1149
  PWN::AI::Agent::Reward.record_preference(prompt: p, rejected: r, chosen: c, source: :user_correction)
879
1150
  PWN::AI::Agent::Reward.preferences(limit: 100)
880
- PWN::AI::Agent::Reward.export_dpo(format: :dpo) # W1 → ~/.pwn/finetune/pwn-dpo-*.jsonl (≤40%/source)
1151
+ PWN::AI::Agent::Reward.export_dpo(format: :dpo) # W1 → ~/.pwn/finetune/pwn-dpo-*.jsonl (≤40%/source, scrubbed)
881
1152
  PWN::AI::Agent::Reward.export_dpo(format: :dpo, balance: false) # raw dump (diagnostics)
1153
+ PWN::AI::Agent::Reward.scrub_preferences(dry_run: true) # P15 ledger hygiene report
1154
+ PWN::AI::Agent::Reward.scrub_preferences # P15 rewrite jsonl (backup first)
1155
+ PWN::AI::Agent::Reward.preference_balance(scrub: true) # P15 geometry-aware mix
882
1156
 
883
1157
  # Tier 6 — grounded reward
884
1158
  PWN::AI::Agent::Reward.verify_as_reward(final: text) # E3 browser-verified reward
@@ -126,3 +126,69 @@ PWN::AI::Agent::Registry.register(
126
126
  )
127
127
  }
128
128
  )
129
+
130
+ PWN::AI::Agent::Registry.register(
131
+ name: 'reward_warm_sentinel',
132
+ toolset: 'learning',
133
+ schema: {
134
+ name: 'reward_warm_sentinel',
135
+ description: 'P10 — Backfill the R3 sentinel ring buffer from Learning ' \
136
+ 'outcomes so proxy_distrust can engage on local/offline hosts ' \
137
+ 'without waiting for live remote introspect. Only fills empty ' \
138
+ 'slots; never flushes a warm window.',
139
+ parameters: {
140
+ type: 'object',
141
+ properties: {
142
+ limit: { type: 'integer', default: 120, description: 'Max outcomes to scan.' }
143
+ },
144
+ required: []
145
+ }
146
+ },
147
+ check: -> { defined?(PWN::AI::Agent::Reward) && PWN::AI::Agent::Reward.respond_to?(:warm_sentinel) },
148
+ handler: ->(args) { PWN::AI::Agent::Reward.warm_sentinel(limit: args[:limit] || 120) }
149
+ )
150
+
151
+ PWN::AI::Agent::Registry.register(
152
+ name: 'reward_scrub_preferences',
153
+ toolset: 'learning',
154
+ schema: {
155
+ name: 'reward_scrub_preferences',
156
+ description: 'P15 — One-shot W1 ledger hygiene. Drops CORRECTION:-only chosen, resolve rows without trajectory shape, and chosen≪rejected pairs. dry_run:true reports; false rewrites ~/.pwn/preferences.jsonl (backup first).',
157
+ parameters: {
158
+ type: 'object',
159
+ properties: {
160
+ dry_run: { type: 'boolean', default: true, description: 'Report only (default true).' }
161
+ },
162
+ required: []
163
+ }
164
+ },
165
+ check: -> { defined?(PWN::AI::Agent::Reward) && PWN::AI::Agent::Reward.respond_to?(:scrub_preferences) },
166
+ handler: lambda { |args|
167
+ dry = args.key?(:dry_run) ? args[:dry_run] : true
168
+ PWN::AI::Agent::Reward.scrub_preferences(dry_run: dry)
169
+ }
170
+ )
171
+
172
+ PWN::AI::Agent::Registry.register(
173
+ name: 'reward_preference_balance',
174
+ toolset: 'learning',
175
+ schema: {
176
+ name: 'reward_preference_balance',
177
+ description: 'P15/P5 — Geometry-aware W1 diversity report. scrub:true scores the post-hygiene diet (trajectory_fraction, by_shape) so promote/export gates see usable pairs only.',
178
+ parameters: {
179
+ type: 'object',
180
+ properties: {
181
+ limit: { type: 'integer', default: 10_000 },
182
+ scrub: { type: 'boolean', default: true }
183
+ },
184
+ required: []
185
+ }
186
+ },
187
+ check: -> { defined?(PWN::AI::Agent::Reward) && PWN::AI::Agent::Reward.respond_to?(:preference_balance) },
188
+ handler: lambda { |args|
189
+ PWN::AI::Agent::Reward.preference_balance(
190
+ limit: args[:limit] || 10_000,
191
+ scrub: args.key?(:scrub) ? args[:scrub] : true
192
+ )
193
+ }
194
+ )
data/lib/pwn/cron.rb CHANGED
@@ -231,8 +231,11 @@ module PWN
231
231
  )
232
232
  end
233
233
 
234
- # P3 — backfill ORM/PRM labels when local :failure_only introspect is on
235
- unless names.include?('curriculum_offline_judge')
234
+ # P3 — backfill ORM/PRM labels when local :failure_only introspect is on.
235
+ # P13 — treat legacy alias offline_judge_nightly as the same job so
236
+ # install_defaults never double-seeds the 30 3 * * * slot.
237
+ offline_names = %w[curriculum_offline_judge offline_judge_nightly]
238
+ unless names.intersect?(offline_names)
236
239
  seeded << create(
237
240
  name: 'curriculum_offline_judge',
238
241
  schedule: '30 3 * * *',
@@ -241,6 +244,15 @@ module PWN
241
244
  enabled: true
242
245
  )
243
246
  end
247
+ # Disable duplicate alias if both exist (idempotent cleanup).
248
+ if names.include?('curriculum_offline_judge') && names.include?('offline_judge_nightly')
249
+ begin
250
+ dup = jobs.values.find { |j| j[:name].to_s == 'offline_judge_nightly' }
251
+ disable(id: dup[:id]) if dup && dup[:enabled]
252
+ rescue StandardError
253
+ nil
254
+ end
255
+ end
244
256
 
245
257
  # M1/M3 — nightly memory GC so the injected MEMORY block stays high-signal
246
258
  unless names.include?('learning_consolidate_nightly')
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.643'
4
+ VERSION = '0.5.647'
5
5
  end