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.
@@ -314,7 +314,7 @@ RSpec.describe 'PWN::AI::Agent reinforced feedback loop', :aggregate_failures do
314
314
  score: 0.85, verdict: :solved,
315
315
  final: 'use `nmap` (typo: nmpa→nmap)',
316
316
  prompt: 'fix nmap typo',
317
- trace: 'shell → nmap -sn 10.0.0.0/24'
317
+ trace: "shell → nmap -sn 10.0.0.0/24\nshell → true\npwn_eval → :ok"
318
318
  )
319
319
  r = curriculum.practice(limit: 1, prompts_per: 2)
320
320
  expect(r[:practiced]).to eq 1
@@ -323,7 +323,13 @@ RSpec.describe 'PWN::AI::Agent reinforced feedback loop', :aggregate_failures do
323
323
  fixed = mistakes.find(signature: sig)
324
324
  expect(fixed[:structured_fix]).to be_a(Hash)
325
325
  expect(fixed[:structured_fix][:strategy]).to eq 'curriculum_practice'
326
- expect(reward.preferences(source: 'curriculum')).not_to be_empty
326
+ expect(fixed[:structured_fix][:winning_trace]).to include('shell ')
327
+ prefs = reward.preferences(source: 'curriculum')
328
+ expect(prefs).not_to be_empty
329
+ # P14 — chosen is trajectory, not first-3-lines fix prose
330
+ expect(prefs.first[:shape].to_s).to eq 'winning_trace'
331
+ expect(prefs.first[:chosen]).to match(/WINNING_TRACE|shell →/i)
332
+ expect(prefs.first[:chosen]).not_to eq 'use `nmap` (typo: nmpa→nmap)'
327
333
  end
328
334
 
329
335
  it 'does NOT auto-resolve with a single holdout success (2.4 gate)' do
@@ -366,16 +372,25 @@ RSpec.describe 'PWN::AI::Agent reinforced feedback loop', :aggregate_failures do
366
372
  @agent_cfg[:counterfactual] = true
367
373
  allow(curriculum).to receive(:ensure_persona).and_return(nil)
368
374
  allow(curriculum).to receive(:ask_persona).and_return('pwn_eval(code: "PWN::Plugins::NmapIt.scan(...)")')
369
- allow(curriculum).to receive(:score_branch) { |o| o[:branch].include?('pwn_eval') ? 0.80 : 0.30 }
375
+ # P9 counterfactual scores via score_branch_detailed
376
+ allow(curriculum).to receive(:score_branch_detailed) do |o|
377
+ if o[:branch].to_s.include?('pwn_eval')
378
+ { score: 0.80, mode: :real_dispatch, trace: o[:branch].to_s }
379
+ else
380
+ { score: 0.30, mode: :imagined, trace: nil }
381
+ end
382
+ end
370
383
 
371
384
  r = curriculum.counterfactual(request: 'scan 10.0.0.1', name: 'shell',
372
385
  args: '{"command":"nmpa -sV"}', error: 'nmpa: not found',
373
386
  hint: 'retry shell with nmap')
374
387
  expect(r[:branch]).to eq :b
375
388
  expect(r[:content]).to include('pwn_eval')
389
+ expect(r[:shape]).to eq :real_dispatch
376
390
  pref = reward.preferences(source: 'counterfactual').first
377
391
  expect(pref[:chosen]).to include('pwn_eval')
378
392
  expect(pref[:rejected]).to include('retry shell')
393
+ expect(pref[:shape].to_s).to eq 'real_dispatch'
379
394
  end
380
395
  end
381
396
 
@@ -415,9 +430,20 @@ RSpec.describe 'PWN::AI::Agent reinforced feedback loop', :aggregate_failures do
415
430
  describe 'W1 · preference-pair generation' do
416
431
  it 'Mistakes.resolve emits a (rejected: failing action, chosen: fix) pair' do
417
432
  m = mistakes.record(tool: 'shell', error: 'nmpa: command not found')
418
- mistakes.resolve(signature: m[:signature], fix: 'use `nmap`, not `nmpa`')
433
+ # P21/P25 W1 pair only lands when structured winning_trace is present
434
+ mistakes.resolve(
435
+ signature: m[:signature],
436
+ fix: 'use `nmap`, not `nmpa`',
437
+ structured: {
438
+ strategy: 'correct_binary_name',
439
+ tool: 'shell',
440
+ winning_trace: "shell → which nmap && nmap --version\nnmap present"
441
+ }
442
+ )
419
443
  pair = reward.preferences(source: 'mistakes_resolve').first
444
+ expect(pair).not_to be_nil
420
445
  expect(pair[:chosen]).to include('nmap')
446
+ expect(pair[:shape].to_s).to eq 'winning_trace'
421
447
  expect(PWN::Memory.load.keys.map(&:to_s)).to include("mistake_fix_#{m[:signature]}")
422
448
  end
423
449
 
@@ -462,17 +488,30 @@ RSpec.describe 'PWN::AI::Agent reinforced feedback loop', :aggregate_failures do
462
488
  expect(r).not_to have_key(:promoted)
463
489
  end
464
490
 
465
- it 'promotes iff resolved(candidate) > resolved(baseline) on the Mistakes eval set' do
491
+ it 'promotes under gate v2 when resolved margin + mean + smoke all win' do
466
492
  allow(learning).to receive(:export_finetune).and_return(path: 'sft', rows: 0)
467
493
  allow(reward).to receive(:export_dpo).and_return(path: 'dpo', rows: 0)
468
494
  allow(curriculum).to receive(:detect_trainer).and_return(:unsloth)
469
495
  allow(curriculum).to receive(:run_trainer).and_return(File.join(@tmp, 'adapter'))
470
496
  allow(curriculum).to receive(:ollama_create).and_return('pwn-v1')
471
- allow(curriculum).to receive(:build_eval_set).and_return(%w[p1 p2 p3])
472
- allow(curriculum).to receive(:ab_gate).and_return(baseline_resolved: 1, candidate_resolved: 3)
497
+ allow(curriculum).to receive(:build_eval_set).and_return(
498
+ Array.new(10) { |i| { signature: "s#{i}", prompt: "p#{i}" } }
499
+ )
500
+ allow(curriculum).to receive(:ab_gate_v2).and_return(
501
+ baseline_resolved: 1, candidate_resolved: 4,
502
+ baseline_mean: 0.5, candidate_mean: 0.8,
503
+ resolved_win: true, mean_win: true, smoke_ok: true,
504
+ promote: true, gate_version: 2, evalset_size: 10
505
+ )
506
+ # P19 — diet gate is AND-ed with ab_gate_v2; stub a clean W1 diet for this contract
507
+ allow(curriculum).to receive(:preference_diet_gate).and_return(
508
+ ok: true, reason: 'diet_ok', total: 40, trajectory_fraction: 0.5, monoculture: false
509
+ )
473
510
  r = curriculum.train_and_gate(dry_run: false, base_model: 'llama3')
474
511
  expect(r[:promoted]).to be true
512
+ expect(r[:weight_loop]).to eq :closed
475
513
  expect(r[:gate][:candidate_resolved]).to be > r[:gate][:baseline_resolved]
514
+ expect(r[:gate][:gate_version]).to eq 2
476
515
  expect(File.exist?(curriculum::MODELS_FILE)).to be true
477
516
  end
478
517
  end
@@ -609,16 +648,171 @@ RSpec.describe 'PWN::AI::Agent reinforced feedback loop', :aggregate_failures do
609
648
  # P-controls — operational fixes that make the loop actually close
610
649
  # ═══════════════════════════════════════════════════════════════════════
611
650
 
651
+ describe 'P9 · W1 pair geometry + write-time source quota' do
652
+ it 'rejects CORRECTION: flaw-prose chosen sides without force' do
653
+ r = reward.record_preference(
654
+ prompt: 'which CVE?', rejected: 'CVE-2099-0001',
655
+ chosen: 'CORRECTION: cited CVE does not exist', source: :critic
656
+ )
657
+ expect(r[:skipped]).to eq :weak_pair_geometry
658
+ expect(reward.preferences.length).to eq 0
659
+ end
660
+
661
+ it 'accepts revised_answer shaped critic pairs' do
662
+ r = reward.record_preference(
663
+ prompt: 'which CVE?', rejected: 'CVE-2099-0001 is critical',
664
+ chosen: "REVISED ANSWER (addresses: CVE does not exist):\nNo such CVE in NVD; verify before citing.",
665
+ source: :critic, shape: :revised_answer
666
+ )
667
+ expect(r[:skipped]).to be_nil
668
+ expect(r[:source]).to eq 'critic'
669
+ expect(r[:shape]).to eq 'revised_answer'
670
+ end
671
+
672
+ it 'write-time quota refuses further mistakes_resolve once over cap' do
673
+ # P25 requires trajectory shape; P9 still caps source share so traj
674
+ # pairs cannot monoculture the ledger. Seed resolve-heavy window.
675
+ 12.times do |i|
676
+ reward.record_preference(
677
+ prompt: "p#{i}", rejected: "r#{i}" * 5,
678
+ chosen: "STRATEGY: s\nWINNING_TRACE:\nshell → ok #{i}\n" + ('t' * 40),
679
+ source: :mistakes_resolve, shape: :winning_trace, force: true
680
+ )
681
+ end
682
+ # one non-resolve so multi-source window, but resolve still >40%
683
+ reward.record_preference(
684
+ prompt: 'q', rejected: 'x' * 20,
685
+ chosen: "REVISED ANSWER:\n#{'y' * 40}",
686
+ source: :critic, shape: :revised_answer, force: true
687
+ )
688
+ r = reward.record_preference(
689
+ prompt: 'pX', rejected: 'rX' * 5,
690
+ chosen: "STRATEGY: s\nWINNING_TRACE:\nshell → next\n#{'t' * 40}",
691
+ source: :mistakes_resolve, shape: :winning_trace
692
+ )
693
+ expect(r[:skipped]).to eq :source_quota
694
+ expect(r[:over_cap]).to be true
695
+ end
696
+
697
+ it 'Curriculum.critic records revised_answer preference not CORRECTION prose' do
698
+ @agent_cfg[:critic] = true
699
+ allow(curriculum).to receive(:ensure_persona).and_return(nil)
700
+ allow(curriculum).to receive(:ask_persona).and_return('FLAW: cited CVE does not exist')
701
+ allow(curriculum).to receive(:revise_after_flaw).and_return(
702
+ "REVISED ANSWER (addresses: cited CVE does not exist):\nUse NVD-verified ids only."
703
+ )
704
+ v = curriculum.critic(request: 'which CVE?', final: 'CVE-2099-0001 is critical')
705
+ expect(v[:verdict]).to eq :flaw
706
+ pref = reward.preferences(source: 'critic').first
707
+ expect(pref).not_to be_nil
708
+ expect(pref[:chosen]).to include('REVISED ANSWER')
709
+ expect(pref[:chosen]).not_to match(/\ACORRECTION:/i)
710
+ expect(pref[:shape].to_s).to eq 'revised_answer'
711
+ end
712
+ end
713
+
714
+ describe 'P10 · Reward.warm_sentinel' do
715
+ it 'fills the R3 window from scored Learning outcomes' do
716
+ 45.times do |i|
717
+ learning.note_outcome(task: "t#{i}", success: i.even?, score: i.even? ? 0.9 : 0.2,
718
+ details: 'x', tags: %w[rspec])
719
+ end
720
+ r = reward.warm_sentinel(limit: 80)
721
+ expect(r[:added]).to be > 0
722
+ expect(r[:samples]).to be >= r[:added]
723
+ expect(%i[warmed_full warmed_partial full]).to include(r[:status])
724
+ end
725
+ end
726
+
727
+ describe 'P11 · ab_gate_v2 promotion contract' do
728
+ it 'requires resolved margin + mean win + smoke ok' do
729
+ allow(curriculum).to receive(:replay_on_detailed) do |o|
730
+ tag = o[:tag].to_s
731
+ evalset = Array(o[:evalset])
732
+ if evalset.any? { |e| e[:signature].to_s.start_with?('smoke_') }
733
+ # smoke equal
734
+ { resolved: 3, mean_score: 0.9, scores: [0.9, 0.9, 0.9] }
735
+ elsif tag.include?('cand') || tag == 'cand'
736
+ { resolved: 8, mean_score: 0.85, scores: [0.85] * 10 }
737
+ else
738
+ { resolved: 5, mean_score: 0.70, scores: [0.7] * 10 }
739
+ end
740
+ end
741
+ allow(curriculum).to receive(:smoke_eval_set).and_return(
742
+ [{ signature: 'smoke_uname', prompt: 'uname' }]
743
+ )
744
+ g = curriculum.send(:ab_gate_v2, baseline: 'base', candidate: 'cand',
745
+ evalset: Array.new(10) { |i| { signature: "s#{i}", prompt: "p#{i}" } })
746
+ expect(g[:gate_version]).to eq 2
747
+ expect(g[:promote]).to be true
748
+ expect(g[:resolved_win]).to be true
749
+ expect(g[:mean_win]).to be true
750
+ expect(g[:smoke_ok]).to be true
751
+ end
752
+
753
+ it 'refuses promotion on smoke regression' do
754
+ allow(curriculum).to receive(:replay_on_detailed) do |o|
755
+ evalset = Array(o[:evalset])
756
+ if evalset.any? { |e| e[:signature].to_s.start_with?('smoke_') }
757
+ tag = o[:tag].to_s
758
+ if tag == 'cand'
759
+ { resolved: 0, mean_score: 0.1, scores: [0.1] }
760
+ else
761
+ { resolved: 3, mean_score: 0.9, scores: [0.9] }
762
+ end
763
+ elsif o[:tag].to_s == 'cand'
764
+ { resolved: 9, mean_score: 0.95, scores: [0.95] * 10 }
765
+ else
766
+ { resolved: 5, mean_score: 0.70, scores: [0.7] * 10 }
767
+ end
768
+ end
769
+ allow(curriculum).to receive(:smoke_eval_set).and_return(
770
+ [{ signature: 'smoke_uname', prompt: 'uname' }]
771
+ )
772
+ g = curriculum.send(:ab_gate_v2, baseline: 'base', candidate: 'cand',
773
+ evalset: Array.new(10) { |i| { signature: "s#{i}", prompt: "p#{i}" } })
774
+ expect(g[:promote]).to be false
775
+ expect(g[:smoke_ok]).to be false
776
+ end
777
+ end
778
+
779
+ describe 'P12 · export_finetune quality gate' do
780
+ it 'drops low-score gold and compresses trajectories' do
781
+ good = PWN::Sessions.create(title: 'sft-good')
782
+ bad = PWN::Sessions.create(title: 'sft-bad')
783
+ [good, bad].each do |s|
784
+ PWN::Sessions.append(session_id: s[:id], role: 'user', content: 'scan lab')
785
+ PWN::Sessions.append(session_id: s[:id], role: 'tool', content: ok_trace)
786
+ PWN::Sessions.append(session_id: s[:id], role: 'assistant', content: '3 hosts up')
787
+ end
788
+ learning.note_outcome(task: 'scan lab', success: true, score: 0.9, session_id: good[:id])
789
+ learning.note_outcome(task: 'scan lab', success: true, score: 0.2, session_id: bad[:id])
790
+ info = learning.export_finetune(out: File.join(@tmp, 'sft-p12.jsonl'))
791
+ expect(info[:samples]).to eq 1
792
+ expect(info[:min_score]).to eq 0.6
793
+ expect(info[:compressed]).to be true
794
+ end
795
+ end
796
+
612
797
  describe 'P5 · export_dpo source-cap (no monoculture in weights)' do
613
798
  it 'downsamples so no single source exceeds DPO_SOURCE_CAP of the corpus' do
614
799
  10.times do |i|
615
- reward.record_preference(prompt: "p#{i}", rejected: "r#{i}", chosen: "c#{i}", source: :mistakes_resolve)
800
+ reward.record_preference(
801
+ prompt: "p#{i}", rejected: "r#{i}", chosen: "WINNING_TRACE:\nshell → ok #{i} " + ('x' * 40),
802
+ source: :mistakes_resolve, shape: :winning_trace, force: true
803
+ )
616
804
  end
617
805
  3.times do |i|
618
- reward.record_preference(prompt: "q#{i}", rejected: "x#{i}", chosen: "y#{i}", source: :counterfactual)
806
+ reward.record_preference(
807
+ prompt: "q#{i}", rejected: "x#{i}", chosen: "y#{i} " + ('alt ' * 20),
808
+ source: :counterfactual, shape: :real_dispatch, force: true
809
+ )
619
810
  end
620
811
  2.times do |i|
621
- reward.record_preference(prompt: "z#{i}", rejected: "a#{i}", chosen: "b#{i}", source: :critic)
812
+ reward.record_preference(
813
+ prompt: "z#{i}", rejected: "a#{i}", chosen: "REVISED ANSWER:\n#{'b' * 40}",
814
+ source: :critic, shape: :revised_answer, force: true
815
+ )
622
816
  end
623
817
  info = reward.export_dpo
624
818
  expect(info[:balanced]).to be true
@@ -627,8 +821,8 @@ RSpec.describe 'PWN::AI::Agent reinforced feedback loop', :aggregate_failures do
627
821
  info[:by_source].each_value do |n|
628
822
  expect(n.to_f / total).to be <= (reward::DPO_SOURCE_CAP + 0.05)
629
823
  end
630
- # raw dump still available for diagnostics
631
- raw = reward.export_dpo(balance: false, out: File.join(@tmp, 'raw-dpo.jsonl'))
824
+ # raw dump still available for diagnostics (no scrub, no balance)
825
+ raw = reward.export_dpo(balance: false, scrub: false, out: File.join(@tmp, 'raw-dpo.jsonl'))
632
826
  expect(raw[:pairs]).to eq 15
633
827
  expect(raw[:balanced]).to be false
634
828
  end
@@ -706,5 +900,306 @@ RSpec.describe 'PWN::AI::Agent reinforced feedback loop', :aggregate_failures do
706
900
  expect(out).to eq 'raw'
707
901
  end
708
902
  end
903
+
904
+ describe 'P14 · practice preference geometry' do
905
+ it 'records winning_trace curriculum pairs instead of fix prose' do
906
+ mistakes.record(tool: 'shell', error: 'p14-geometry-unique-zz')
907
+ allow(curriculum).to receive(:reflect_available?).and_return(false)
908
+ allow(curriculum).to receive(:self_play).and_return(
909
+ score: 0.9, verdict: :solved,
910
+ final: "fixed with nmap\nsecond line\nthird",
911
+ prompt: 'scan the lab safely',
912
+ trace: "shell → nmap -sn 10.0.0.0/24\nshell → true"
913
+ )
914
+ r = curriculum.practice(limit: 1, prompts_per: 2)
915
+ expect(r[:resolved]).to eq 1
916
+ pref = reward.preferences(source: 'curriculum').first
917
+ expect(pref[:shape].to_s).to eq 'winning_trace'
918
+ expect(pref[:chosen]).to include('WINNING_TRACE')
919
+ expect(pref[:chosen]).to include('nmap')
920
+ end
921
+ end
922
+
923
+ describe 'P15 · preference ledger hygiene' do
924
+ it 'usable_preference? drops CORRECTION prose and short resolve commentary' do
925
+ expect(reward.usable_preference?(
926
+ prompt: 'p', rejected: 'long ' * 80, chosen: 'CORRECTION: no', source: 'critic'
927
+ )).to be false
928
+ expect(reward.usable_preference?(
929
+ prompt: 'p', rejected: 'fail',
930
+ chosen: "STRATEGY: x\nWINNING_TRACE:\nshell → ok\n#{'x' * 80}",
931
+ source: 'curriculum', shape: 'winning_trace'
932
+ )).to be true
933
+ end
934
+
935
+ it 'scrub_preferences dry_run reports drops without rewriting' do
936
+ reward.record_preference(
937
+ prompt: 'p', rejected: 'bad final answer that is fairly long ' * 5,
938
+ chosen: 'CORRECTION: short', source: :critic, force: true
939
+ )
940
+ reward.record_preference(
941
+ prompt: 'q', rejected: 'old', chosen: "REVISED ANSWER:\n#{'ok ' * 40}",
942
+ source: :critic, shape: :revised_answer, force: true
943
+ )
944
+ r = reward.scrub_preferences(dry_run: true)
945
+ expect(r[:before]).to be >= 2
946
+ expect(r[:dropped]).to be >= 1
947
+ expect(r[:dry_run]).to be true
948
+ expect(reward.preferences.length).to eq r[:before]
949
+ end
950
+
951
+ it 'export_dpo scrub drops weak geometry before source cap' do
952
+ 5.times do |i|
953
+ reward.record_preference(
954
+ prompt: "p#{i}", rejected: 'long rejected final ' * 30,
955
+ chosen: 'CORRECTION: nope', source: :mistakes_resolve, force: true
956
+ )
957
+ end
958
+ 5.times do |i|
959
+ reward.record_preference(
960
+ prompt: "q#{i}", rejected: "r#{i}",
961
+ chosen: "WINNING_TRACE:\nshell → uname -r\n#{i} " + ('trace ' * 20),
962
+ source: :counterfactual, shape: :real_dispatch, force: true
963
+ )
964
+ end
965
+ info = reward.export_dpo(out: File.join(@tmp, 'p15-dpo.jsonl'))
966
+ expect(info[:geometry_dropped]).to be >= 5
967
+ expect(info[:scrubbed]).to be true
968
+ lines = File.readlines(info[:path]).map { |l| JSON.parse(l) }
969
+ expect(lines).not_to be_empty
970
+ expect(lines.any? { |l| l['chosen'].to_s.start_with?('CORRECTION:') }).to be false
971
+ end
972
+
973
+ it 'preference_balance(scrub: true) reports trajectory_fraction' do
974
+ reward.record_preference(
975
+ prompt: 'a', rejected: 'x', chosen: "WINNING_TRACE:\n#{'t' * 100}",
976
+ source: :curriculum, shape: :winning_trace, force: true
977
+ )
978
+ bal = reward.preference_balance(scrub: true)
979
+ expect(bal[:trajectory_fraction]).to be >= 0.0
980
+ expect(bal).to have_key(:by_shape)
981
+ expect(bal[:kept]).to be <= bal[:total]
982
+ end
983
+ end
984
+
985
+ describe 'P16 · warm_sentinel engages controllers' do
986
+ it 'fills to capacity and exposes proxy_distrust' do
987
+ 50.times do |i|
988
+ learning.note_outcome(task: "w#{i}", success: i.even?, score: i.even? ? 0.85 : 0.2,
989
+ details: 'x', tags: %w[rspec p16])
990
+ end
991
+ r = reward.warm_sentinel(limit: 120)
992
+ expect(r[:samples]).to be >= r[:added]
993
+ expect(r).to have_key(:proxy_distrust)
994
+ expect(%i[warmed_full warmed_partial full]).to include(r[:status])
995
+ end
996
+ end
997
+
998
+ describe 'P17 · budget-exhaustion curriculum target' do
999
+ it 'natural_repro_prompts for agent_loop favour short-finish tasks' do
1000
+ prompts = curriculum.send(
1001
+ :natural_repro_prompts,
1002
+ mistake: { tool: 'agent_loop', error: 'iteration budget exhausted', shape: 'handler_error' },
1003
+ count: 4
1004
+ )
1005
+ expect(prompts.length).to eq 4
1006
+ blob = prompts.join(' ').downcase
1007
+ expect(blob).to match(/one shell|at most two|no tools|three iterations|uname|cwd/)
1008
+ end
1009
+
1010
+ it 'practice sorts budget fingerprints ahead of shell noise' do
1011
+ mistakes.record(tool: 'shell', error: 'shell-noise-unique-aaa')
1012
+ 3.times { mistakes.record(tool: 'agent_loop', error: 'iteration budget exhausted without a final answer') }
1013
+ allow(curriculum).to receive(:reflect_available?).and_return(false)
1014
+ allow(curriculum).to receive(:practice_skip?).and_return(false)
1015
+ seen = []
1016
+ allow(curriculum).to receive(:generate_reproducers) do |o|
1017
+ seen << o[:mistake][:tool]
1018
+ ['Answer in one shell call: print kernel release with uname -r']
1019
+ end
1020
+ curriculum.practice(limit: 1, prompts_per: 1, dry_run: true)
1021
+ expect(seen.first).to eq 'agent_loop'
1022
+ end
1023
+ end
1024
+
1025
+ describe 'P18 · PRM closes into Registry.rank' do
1026
+ it 'Metrics.record_step_reward shifts prm_advantage and rank scoring' do
1027
+ 10.times { metrics.record_step_reward(name: 'shell', reward: 1.0) }
1028
+ 10.times { metrics.record_step_reward(name: 'extro_rf_tune', reward: -1.0) }
1029
+ expect(metrics.prm_advantage(name: 'shell')).to be > metrics.prm_advantage(name: 'extro_rf_tune')
1030
+ ranked = registry.rank(query: 'run a shell command on the host')
1031
+ expect(ranked.first.name).to eq 'shell'
1032
+ end
1033
+ end
1034
+
1035
+ describe 'P19 · train_and_gate diet gate blocks promote on weak W1' do
1036
+ it 'preference_diet_gate fails when trajectory_fraction is low' do
1037
+ allow(reward).to receive(:preference_balance).and_return(
1038
+ total: 20, kept: 20, monoculture: true, fractions: { 'mistakes_resolve' => 0.9 },
1039
+ trajectory_fraction: 0.05, by_source: { 'mistakes_resolve' => 18 }
1040
+ )
1041
+ g = curriculum.send(:preference_diet_gate)
1042
+ expect(g[:ok]).to be false
1043
+ end
1044
+
1045
+ it 'preference_diet_gate passes on diverse trajectory diet' do
1046
+ allow(reward).to receive(:preference_balance).and_return(
1047
+ total: 40, kept: 40, monoculture: false,
1048
+ fractions: { 'curriculum' => 0.3, 'critic' => 0.3, 'counterfactual' => 0.25, 'mistakes_resolve' => 0.15 },
1049
+ trajectory_fraction: 0.55, by_source: { 'curriculum' => 12 }
1050
+ )
1051
+ g = curriculum.send(:preference_diet_gate)
1052
+ expect(g[:ok]).to be true
1053
+ expect(g[:reason]).to eq 'diet_ok'
1054
+ end
1055
+ end
1056
+
1057
+ describe 'P20 · judge-blended Metrics scalar' do
1058
+ it 'record_judge shifts effective_rate and advantage under distrust' do
1059
+ allow(reward).to receive(:proxy_distrust).and_return(0.8)
1060
+ 20.times { metrics.record(name: 'shell', success: true, duration: 0.1) }
1061
+ 20.times { metrics.record(name: 'extro_rf_tune', success: true, duration: 0.1) }
1062
+ 10.times { metrics.record_judge(name: 'shell', score: 0.9) }
1063
+ 10.times { metrics.record_judge(name: 'extro_rf_tune', score: 0.1) }
1064
+ expect(metrics.judge_rate(name: 'shell')).to be > 0.8
1065
+ expect(metrics.judge_rate(name: 'extro_rf_tune')).to be < 0.2
1066
+ expect(metrics.effective_rate(name: 'shell')).to be > metrics.effective_rate(name: 'extro_rf_tune')
1067
+ expect(metrics.advantage(name: 'shell')).to be > metrics.advantage(name: 'extro_rf_tune')
1068
+ end
1069
+
1070
+ it 'exemplars_for drops low-score success rows' do
1071
+ learning.note_outcome(task: 'p20 high judge shell uname', success: true, score: 0.95,
1072
+ details: 'ok', session_id: 'sess-p20-hi', tags: %w[rspec p20])
1073
+ learning.note_outcome(task: 'p20 low judge shell uname', success: true, score: 0.2,
1074
+ details: 'proxy lie', session_id: 'sess-p20-lo', tags: %w[rspec p20])
1075
+ # compress_exemplar needs real sessions — just ensure filter does not raise
1076
+ # and low score alone is excluded from pool scoring path
1077
+ pool = learning.outcomes(limit: 50, success: true)
1078
+ low = pool.select { |r| r[:score].to_f < 0.6 && r[:task].to_s.include?('p20 low') }
1079
+ expect(low).not_to be_empty
1080
+ # unit-check the filter predicate used in exemplars_for
1081
+ kept = pool.reject { |r| r.key?(:score) && r[:score].to_f < 0.6 }
1082
+ expect(kept.any? { |r| r[:task].to_s.include?('p20 low') }).to be false
1083
+ expect(kept.any? { |r| r[:task].to_s.include?('p20 high') }).to be true
1084
+ end
1085
+ end
1086
+
1087
+ describe 'P21/P25 · trajectory-only W1 writers' do
1088
+ it 'record_preference refuses non-trajectory shape without force' do
1089
+ r = reward.record_preference(
1090
+ prompt: 'p', rejected: 'bad', chosen: 'do this instead with more words here',
1091
+ source: :mistakes_resolve, shape: :fix_prose
1092
+ )
1093
+ expect(r).to be_a(Hash)
1094
+ expect(r[:skipped]).to eq :non_trajectory_shape
1095
+ end
1096
+
1097
+ it 'record_preference accepts winning_trace shape' do
1098
+ r = reward.record_preference(
1099
+ prompt: 'p', rejected: 'failing shell args',
1100
+ chosen: "STRATEGY: x\nWINNING_TRACE:\nshell → uname -r\n#{'t' * 40}",
1101
+ source: :mistakes_resolve, shape: :winning_trace
1102
+ )
1103
+ expect(r).to be_a(Hash)
1104
+ expect(r[:skipped]).to be_nil
1105
+ expect(r[:shape].to_s).to eq 'winning_trace'
1106
+ end
1107
+
1108
+ it 'mistakes.resolve does not write fix_prose preference pairs' do
1109
+ m = mistakes.record(tool: 'shell', error: 'p25-no-prose-unique-zz')
1110
+ before = reward.preferences.length
1111
+ mistakes.resolve(signature: m[:signature], fix: 'use a different flag next time')
1112
+ # no structured winning_trace → no new preference row
1113
+ expect(reward.preferences.length).to eq before
1114
+ end
1115
+
1116
+ it 'mistakes.resolve writes winning_trace when structured_fix has trace' do
1117
+ m = mistakes.record(tool: 'shell', error: 'p25-with-trace-unique-yy')
1118
+ before = reward.preferences.length
1119
+ # winning_trace must be ≥40 chars (P21 gate inside Mistakes.resolve)
1120
+ mistakes.resolve(
1121
+ signature: m[:signature],
1122
+ fix: 'run uname -r once',
1123
+ structured: {
1124
+ strategy: 'short_horizon_finish',
1125
+ tool: 'shell',
1126
+ winning_trace: "shell → uname -r\n6.19.14-kali\n(kernel release)"
1127
+ }
1128
+ )
1129
+ expect(reward.preferences.length).to be > before
1130
+ pref = reward.preferences(source: 'mistakes_resolve').find { |p| p[:chosen].to_s.include?('uname') }
1131
+ expect(pref).not_to be_nil
1132
+ expect(pref[:shape].to_s).to eq 'winning_trace'
1133
+ end
1134
+ end
1135
+
1136
+ describe 'P22 · W3 calibration lights up from plan_first' do
1137
+ it 'plan_first parse accepts p(success)= and stashes on Thread' do
1138
+ # Direct unit: emulate stash write path used by plan_first
1139
+ Thread.current[:pwn_plan_predicted] = 0.72
1140
+ expect(Thread.current[:pwn_plan_predicted]).to eq 0.72
1141
+ # calibrate path
1142
+ r = curriculum.calibrate(predicted: 0.72, actual: 1.0, engine: :ollama)
1143
+ expect(r[:brier]).to be_a(Numeric)
1144
+ expect(metrics.calibration(engine: :ollama)[:n]).to be >= 1
1145
+ Thread.current[:pwn_plan_predicted] = nil
1146
+ end
1147
+
1148
+ it 'recover_predicted_from_session reads Thread stash' do
1149
+ Thread.current[:pwn_plan_predicted] = 0.55
1150
+ pred = learning.send(:recover_predicted_from_session, session_id: 'missing')
1151
+ expect(pred).to eq 0.55
1152
+ Thread.current[:pwn_plan_predicted] = nil
1153
+ end
1154
+ end
1155
+
1156
+ describe 'P23 · short-horizon budget practice' do
1157
+ it 'self_play detects short-horizon prompt language' do
1158
+ # natural prompts already short-horizon; ensure generator still emits them
1159
+ prompts = curriculum.send(
1160
+ :natural_repro_prompts,
1161
+ mistake: { tool: 'agent_loop', error: 'iteration budget exhausted' },
1162
+ count: 3
1163
+ )
1164
+ expect(prompts.join(' ')).to match(/one shell|at most two|no tools|three iterations/i)
1165
+ end
1166
+
1167
+ it 'practice refuses resolve when holdouts ok but trace weak on budget target' do
1168
+ mistakes.record(tool: 'agent_loop', error: 'p23-weak-trace-budget-zz')
1169
+ allow(curriculum).to receive(:reflect_available?).and_return(false)
1170
+ allow(curriculum).to receive(:self_play).and_return(
1171
+ score: 0.9, verdict: :solved,
1172
+ final: 'x' * 2000, # long prose, no tool arrow
1173
+ prompt: 'Answer in one shell call: print kernel release with uname -r',
1174
+ trace: '' # empty trace
1175
+ )
1176
+ r = curriculum.practice(limit: 1, prompts_per: 2)
1177
+ row = r[:results]&.first || (r[:practiced] && nil)
1178
+ # practice returns practiced/resolved counts; weak trace should not resolve
1179
+ expect(r[:resolved]).to eq 0
1180
+ end
1181
+ end
1182
+
1183
+ describe 'P24 · critic cost capped under budget hot' do
1184
+ it 'critic(text_only: true) returns without persona tools' do
1185
+ allow(curriculum).to receive(:reflect_available?).and_return(false)
1186
+ r = curriculum.critic(
1187
+ request: 'what is kernel',
1188
+ final: '[pwn-ai] iteration budget exhausted',
1189
+ text_only: true
1190
+ )
1191
+ expect(r[:source].to_s).to include('text_only')
1192
+ expect(%i[pass flaw]).to include(r[:verdict])
1193
+ expect(r[:verdict]).to eq :flaw # heuristic sees budget exhausted
1194
+ end
1195
+
1196
+ it 'critic(text_only: true) does not require critic env flag' do
1197
+ # even when critic disabled, text_only path works (forced by budget hot)
1198
+ allow(curriculum).to receive(:enabled?).and_return(false)
1199
+ allow(curriculum).to receive(:reflect_available?).and_return(false)
1200
+ r = curriculum.critic(request: 'q', final: 'solid answer with facts', text_only: true)
1201
+ expect(r[:verdict]).to eq :pass
1202
+ end
1203
+ end
709
1204
  end
710
1205
  # rubocop:enable Metrics/BlockLength
@@ -35,7 +35,12 @@ describe PWN::AI::Agent::Reward do
35
35
  stub_const('PWN::AI::Agent::Reward::DPO_DIR', tmp)
36
36
 
37
37
  described_class.record_preference(prompt: 'p', rejected: 'bad', chosen: 'good', source: :user_correction)
38
- described_class.record_preference(prompt: 'p2', rejected: 'x', chosen: 'y', source: :mistakes_resolve)
38
+ # P21/P25 mistakes_resolve requires a trajectory shape
39
+ described_class.record_preference(
40
+ prompt: 'p2', rejected: 'failing shell args',
41
+ chosen: "STRATEGY: x\nWINNING_TRACE:\nshell → uname -r\n#{'t' * 40}",
42
+ source: :mistakes_resolve, shape: :winning_trace
43
+ )
39
44
  expect(described_class.preferences.length).to eq 2
40
45
  expect(described_class.preferences(source: 'user_correction').length).to eq 1
41
46
 
@@ -144,4 +149,62 @@ describe PWN::AI::Agent::Reward do
144
149
  expect(File.exist?(File.join(tmp, 's.json'))).to be false
145
150
  expect(File.exist?(File.join(tmp, 'prefs.jsonl'))).to be true
146
151
  end
152
+ it 'write_source_quota and weak pair geometry (P9)' do
153
+ tmp = Dir.mktmpdir
154
+ stub_const('PWN::AI::Agent::Reward::PREFERENCES_FILE', File.join(tmp, 'prefs.jsonl'))
155
+ r = described_class.record_preference(prompt: 'p', rejected: 'a', chosen: 'CORRECTION: no', source: :critic)
156
+ expect(r[:skipped]).to eq :weak_pair_geometry
157
+ 11.times do |i|
158
+ described_class.record_preference(prompt: "p#{i}", rejected: "r#{i}", chosen: "c#{i}", source: :mistakes_resolve, force: true)
159
+ end
160
+ q = described_class.write_source_quota(source: 'mistakes_resolve')
161
+ expect(q[:over_cap]).to be true
162
+ end
163
+
164
+ it 'warm_sentinel fills from learning outcomes (P10)' do
165
+ tmp = Dir.mktmpdir
166
+ stub_const('PWN::AI::Agent::Reward::SENTINEL_FILE', File.join(tmp, 's.json'))
167
+ stub_const('PWN::AI::Agent::Learning::LEARNING_FILE', File.join(tmp, 'l.jsonl'))
168
+ 50.times do |i|
169
+ PWN::AI::Agent::Learning.note_outcome(task: "t#{i}", success: true, score: 0.8, details: 'd')
170
+ end
171
+ r = described_class.warm_sentinel(limit: 60)
172
+ expect(r[:added]).to be > 0
173
+ end
174
+
175
+ it 'scrub_preferences and usable_preference? (P15)' do
176
+ tmp = Dir.mktmpdir
177
+ stub_const('PWN::AI::Agent::Reward::PREFERENCES_FILE', File.join(tmp, 'prefs.jsonl'))
178
+ described_class.record_preference(
179
+ prompt: 'p', rejected: 'long rejected text ' * 20,
180
+ chosen: 'CORRECTION: no', source: :critic, force: true
181
+ )
182
+ described_class.record_preference(
183
+ prompt: 'q', rejected: 'bad',
184
+ chosen: "WINNING_TRACE:\n#{'shell ok ' * 30}",
185
+ source: :curriculum, shape: :winning_trace, force: true
186
+ )
187
+ dry = described_class.scrub_preferences(dry_run: true)
188
+ expect(dry[:dropped]).to be >= 1
189
+ wet = described_class.scrub_preferences(dry_run: false)
190
+ expect(wet[:after]).to be < wet[:before]
191
+ bal = described_class.preference_balance(scrub: true)
192
+ expect(bal).to have_key(:trajectory_fraction)
193
+ end
194
+
195
+ it 'export_dpo reports geometry_dropped (P15)' do
196
+ tmp = Dir.mktmpdir
197
+ stub_const('PWN::AI::Agent::Reward::PREFERENCES_FILE', File.join(tmp, 'prefs.jsonl'))
198
+ stub_const('PWN::AI::Agent::Reward::DPO_DIR', tmp)
199
+ described_class.record_preference(
200
+ prompt: 'p', rejected: 'r' * 500, chosen: 'CORRECTION: x', source: :mistakes_resolve, force: true
201
+ )
202
+ described_class.record_preference(
203
+ prompt: 'q', rejected: 'r', chosen: "WINNING_TRACE:\n#{'t' * 100}",
204
+ source: :counterfactual, shape: :real_dispatch, force: true
205
+ )
206
+ info = described_class.export_dpo(out: File.join(tmp, 'd.jsonl'))
207
+ expect(info[:geometry_dropped]).to be >= 1
208
+ expect(info[:scrubbed]).to be true
209
+ end
147
210
  end