pwn 0.5.643 → 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.
@@ -44,6 +44,7 @@ RSpec.describe 'PWN::AI::Agent reinforced feedback loop', :aggregate_failures do
44
44
  stub_const('PWN::AI::Agent::Reward::DPO_DIR', File.join(@tmp, 'finetune'))
45
45
  stub_const('PWN::AI::Agent::Curriculum::CURRICULUM_DIR', File.join(@tmp, 'curriculum'))
46
46
  stub_const('PWN::AI::Agent::Curriculum::MODELS_FILE', File.join(@tmp, 'curriculum', 'models.json'))
47
+ stub_const('PWN::AI::Agent::Curriculum::KPI_FILE', File.join(@tmp, 'curriculum_kpi.jsonl'))
47
48
  stub_const('PWN::Sessions::SESSIONS_DIR', File.join(@tmp, 'sessions'))
48
49
  stub_const('PWN::Memory::MEMORY_FILE', File.join(@tmp, 'memory.json'))
49
50
  stub_const('PWN::MemoryIndex::INDEX_FILE', File.join(@tmp, 'memory.idx')) if defined?(PWN::MemoryIndex)
@@ -314,7 +315,7 @@ RSpec.describe 'PWN::AI::Agent reinforced feedback loop', :aggregate_failures do
314
315
  score: 0.85, verdict: :solved,
315
316
  final: 'use `nmap` (typo: nmpa→nmap)',
316
317
  prompt: 'fix nmap typo',
317
- trace: 'shell → nmap -sn 10.0.0.0/24'
318
+ trace: "shell → nmap -sn 10.0.0.0/24\nshell → true\npwn_eval → :ok"
318
319
  )
319
320
  r = curriculum.practice(limit: 1, prompts_per: 2)
320
321
  expect(r[:practiced]).to eq 1
@@ -323,7 +324,13 @@ RSpec.describe 'PWN::AI::Agent reinforced feedback loop', :aggregate_failures do
323
324
  fixed = mistakes.find(signature: sig)
324
325
  expect(fixed[:structured_fix]).to be_a(Hash)
325
326
  expect(fixed[:structured_fix][:strategy]).to eq 'curriculum_practice'
326
- expect(reward.preferences(source: 'curriculum')).not_to be_empty
327
+ expect(fixed[:structured_fix][:winning_trace]).to include('shell →')
328
+ prefs = reward.preferences(source: 'curriculum')
329
+ expect(prefs).not_to be_empty
330
+ # P14 — chosen is trajectory, not first-3-lines fix prose
331
+ expect(prefs.first[:shape].to_s).to eq 'winning_trace'
332
+ expect(prefs.first[:chosen]).to match(/WINNING_TRACE|shell →/i)
333
+ expect(prefs.first[:chosen]).not_to eq 'use `nmap` (typo: nmpa→nmap)'
327
334
  end
328
335
 
329
336
  it 'does NOT auto-resolve with a single holdout success (2.4 gate)' do
@@ -366,16 +373,25 @@ RSpec.describe 'PWN::AI::Agent reinforced feedback loop', :aggregate_failures do
366
373
  @agent_cfg[:counterfactual] = true
367
374
  allow(curriculum).to receive(:ensure_persona).and_return(nil)
368
375
  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 }
376
+ # P9 — counterfactual scores via score_branch_detailed
377
+ allow(curriculum).to receive(:score_branch_detailed) do |o|
378
+ if o[:branch].to_s.include?('pwn_eval')
379
+ { score: 0.80, mode: :real_dispatch, trace: o[:branch].to_s }
380
+ else
381
+ { score: 0.30, mode: :imagined, trace: nil }
382
+ end
383
+ end
370
384
 
371
385
  r = curriculum.counterfactual(request: 'scan 10.0.0.1', name: 'shell',
372
386
  args: '{"command":"nmpa -sV"}', error: 'nmpa: not found',
373
387
  hint: 'retry shell with nmap')
374
388
  expect(r[:branch]).to eq :b
375
389
  expect(r[:content]).to include('pwn_eval')
390
+ expect(r[:shape]).to eq :real_dispatch
376
391
  pref = reward.preferences(source: 'counterfactual').first
377
392
  expect(pref[:chosen]).to include('pwn_eval')
378
393
  expect(pref[:rejected]).to include('retry shell')
394
+ expect(pref[:shape].to_s).to eq 'real_dispatch'
379
395
  end
380
396
  end
381
397
 
@@ -415,9 +431,20 @@ RSpec.describe 'PWN::AI::Agent reinforced feedback loop', :aggregate_failures do
415
431
  describe 'W1 · preference-pair generation' do
416
432
  it 'Mistakes.resolve emits a (rejected: failing action, chosen: fix) pair' do
417
433
  m = mistakes.record(tool: 'shell', error: 'nmpa: command not found')
418
- mistakes.resolve(signature: m[:signature], fix: 'use `nmap`, not `nmpa`')
434
+ # P21/P25 — W1 pair only lands when structured winning_trace is present
435
+ mistakes.resolve(
436
+ signature: m[:signature],
437
+ fix: 'use `nmap`, not `nmpa`',
438
+ structured: {
439
+ strategy: 'correct_binary_name',
440
+ tool: 'shell',
441
+ winning_trace: "shell → which nmap && nmap --version\nnmap present"
442
+ }
443
+ )
419
444
  pair = reward.preferences(source: 'mistakes_resolve').first
445
+ expect(pair).not_to be_nil
420
446
  expect(pair[:chosen]).to include('nmap')
447
+ expect(pair[:shape].to_s).to eq 'winning_trace'
421
448
  expect(PWN::Memory.load.keys.map(&:to_s)).to include("mistake_fix_#{m[:signature]}")
422
449
  end
423
450
 
@@ -462,17 +489,30 @@ RSpec.describe 'PWN::AI::Agent reinforced feedback loop', :aggregate_failures do
462
489
  expect(r).not_to have_key(:promoted)
463
490
  end
464
491
 
465
- it 'promotes iff resolved(candidate) > resolved(baseline) on the Mistakes eval set' do
492
+ it 'promotes under gate v2 when resolved margin + mean + smoke all win' do
466
493
  allow(learning).to receive(:export_finetune).and_return(path: 'sft', rows: 0)
467
494
  allow(reward).to receive(:export_dpo).and_return(path: 'dpo', rows: 0)
468
495
  allow(curriculum).to receive(:detect_trainer).and_return(:unsloth)
469
496
  allow(curriculum).to receive(:run_trainer).and_return(File.join(@tmp, 'adapter'))
470
497
  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)
498
+ allow(curriculum).to receive(:build_eval_set).and_return(
499
+ Array.new(10) { |i| { signature: "s#{i}", prompt: "p#{i}" } }
500
+ )
501
+ allow(curriculum).to receive(:ab_gate_v2).and_return(
502
+ baseline_resolved: 1, candidate_resolved: 4,
503
+ baseline_mean: 0.5, candidate_mean: 0.8,
504
+ resolved_win: true, mean_win: true, smoke_ok: true,
505
+ promote: true, gate_version: 2, evalset_size: 10
506
+ )
507
+ # P19 — diet gate is AND-ed with ab_gate_v2; stub a clean W1 diet for this contract
508
+ allow(curriculum).to receive(:preference_diet_gate).and_return(
509
+ ok: true, reason: 'diet_ok', total: 40, trajectory_fraction: 0.5, monoculture: false
510
+ )
473
511
  r = curriculum.train_and_gate(dry_run: false, base_model: 'llama3')
474
512
  expect(r[:promoted]).to be true
513
+ expect(r[:weight_loop]).to eq :closed
475
514
  expect(r[:gate][:candidate_resolved]).to be > r[:gate][:baseline_resolved]
515
+ expect(r[:gate][:gate_version]).to eq 2
476
516
  expect(File.exist?(curriculum::MODELS_FILE)).to be true
477
517
  end
478
518
  end
@@ -609,16 +649,171 @@ RSpec.describe 'PWN::AI::Agent reinforced feedback loop', :aggregate_failures do
609
649
  # P-controls — operational fixes that make the loop actually close
610
650
  # ═══════════════════════════════════════════════════════════════════════
611
651
 
652
+ describe 'P9 · W1 pair geometry + write-time source quota' do
653
+ it 'rejects CORRECTION: flaw-prose chosen sides without force' do
654
+ r = reward.record_preference(
655
+ prompt: 'which CVE?', rejected: 'CVE-2099-0001',
656
+ chosen: 'CORRECTION: cited CVE does not exist', source: :critic
657
+ )
658
+ expect(r[:skipped]).to eq :weak_pair_geometry
659
+ expect(reward.preferences.length).to eq 0
660
+ end
661
+
662
+ it 'accepts revised_answer shaped critic pairs' do
663
+ r = reward.record_preference(
664
+ prompt: 'which CVE?', rejected: 'CVE-2099-0001 is critical',
665
+ chosen: "REVISED ANSWER (addresses: CVE does not exist):\nNo such CVE in NVD; verify before citing.",
666
+ source: :critic, shape: :revised_answer
667
+ )
668
+ expect(r[:skipped]).to be_nil
669
+ expect(r[:source]).to eq 'critic'
670
+ expect(r[:shape]).to eq 'revised_answer'
671
+ end
672
+
673
+ it 'write-time quota refuses further mistakes_resolve once over cap' do
674
+ # P25 requires trajectory shape; P9 still caps source share so traj
675
+ # pairs cannot monoculture the ledger. Seed resolve-heavy window.
676
+ 12.times do |i|
677
+ reward.record_preference(
678
+ prompt: "p#{i}", rejected: "r#{i}" * 5,
679
+ chosen: "STRATEGY: s\nWINNING_TRACE:\nshell → ok #{i}\n" + ('t' * 40),
680
+ source: :mistakes_resolve, shape: :winning_trace, force: true
681
+ )
682
+ end
683
+ # one non-resolve so multi-source window, but resolve still >40%
684
+ reward.record_preference(
685
+ prompt: 'q', rejected: 'x' * 20,
686
+ chosen: "REVISED ANSWER:\n#{'y' * 40}",
687
+ source: :critic, shape: :revised_answer, force: true
688
+ )
689
+ r = reward.record_preference(
690
+ prompt: 'pX', rejected: 'rX' * 5,
691
+ chosen: "STRATEGY: s\nWINNING_TRACE:\nshell → next\n#{'t' * 40}",
692
+ source: :mistakes_resolve, shape: :winning_trace
693
+ )
694
+ expect(r[:skipped]).to eq :source_quota
695
+ expect(r[:over_cap]).to be true
696
+ end
697
+
698
+ it 'Curriculum.critic records revised_answer preference not CORRECTION prose' do
699
+ @agent_cfg[:critic] = true
700
+ allow(curriculum).to receive(:ensure_persona).and_return(nil)
701
+ allow(curriculum).to receive(:ask_persona).and_return('FLAW: cited CVE does not exist')
702
+ allow(curriculum).to receive(:revise_after_flaw).and_return(
703
+ "REVISED ANSWER (addresses: cited CVE does not exist):\nUse NVD-verified ids only."
704
+ )
705
+ v = curriculum.critic(request: 'which CVE?', final: 'CVE-2099-0001 is critical')
706
+ expect(v[:verdict]).to eq :flaw
707
+ pref = reward.preferences(source: 'critic').first
708
+ expect(pref).not_to be_nil
709
+ expect(pref[:chosen]).to include('REVISED ANSWER')
710
+ expect(pref[:chosen]).not_to match(/\ACORRECTION:/i)
711
+ expect(pref[:shape].to_s).to eq 'revised_answer'
712
+ end
713
+ end
714
+
715
+ describe 'P10 · Reward.warm_sentinel' do
716
+ it 'fills the R3 window from scored Learning outcomes' do
717
+ 45.times do |i|
718
+ learning.note_outcome(task: "t#{i}", success: i.even?, score: i.even? ? 0.9 : 0.2,
719
+ details: 'x', tags: %w[rspec])
720
+ end
721
+ r = reward.warm_sentinel(limit: 80)
722
+ expect(r[:added]).to be > 0
723
+ expect(r[:samples]).to be >= r[:added]
724
+ expect(%i[warmed_full warmed_partial full]).to include(r[:status])
725
+ end
726
+ end
727
+
728
+ describe 'P11 · ab_gate_v2 promotion contract' do
729
+ it 'requires resolved margin + mean win + smoke ok' do
730
+ allow(curriculum).to receive(:replay_on_detailed) do |o|
731
+ tag = o[:tag].to_s
732
+ evalset = Array(o[:evalset])
733
+ if evalset.any? { |e| e[:signature].to_s.start_with?('smoke_') }
734
+ # smoke equal
735
+ { resolved: 3, mean_score: 0.9, scores: [0.9, 0.9, 0.9] }
736
+ elsif tag.include?('cand') || tag == 'cand'
737
+ { resolved: 8, mean_score: 0.85, scores: [0.85] * 10 }
738
+ else
739
+ { resolved: 5, mean_score: 0.70, scores: [0.7] * 10 }
740
+ end
741
+ end
742
+ allow(curriculum).to receive(:smoke_eval_set).and_return(
743
+ [{ signature: 'smoke_uname', prompt: 'uname' }]
744
+ )
745
+ g = curriculum.send(:ab_gate_v2, baseline: 'base', candidate: 'cand',
746
+ evalset: Array.new(10) { |i| { signature: "s#{i}", prompt: "p#{i}" } })
747
+ expect(g[:gate_version]).to eq 2
748
+ expect(g[:promote]).to be true
749
+ expect(g[:resolved_win]).to be true
750
+ expect(g[:mean_win]).to be true
751
+ expect(g[:smoke_ok]).to be true
752
+ end
753
+
754
+ it 'refuses promotion on smoke regression' do
755
+ allow(curriculum).to receive(:replay_on_detailed) do |o|
756
+ evalset = Array(o[:evalset])
757
+ if evalset.any? { |e| e[:signature].to_s.start_with?('smoke_') }
758
+ tag = o[:tag].to_s
759
+ if tag == 'cand'
760
+ { resolved: 0, mean_score: 0.1, scores: [0.1] }
761
+ else
762
+ { resolved: 3, mean_score: 0.9, scores: [0.9] }
763
+ end
764
+ elsif o[:tag].to_s == 'cand'
765
+ { resolved: 9, mean_score: 0.95, scores: [0.95] * 10 }
766
+ else
767
+ { resolved: 5, mean_score: 0.70, scores: [0.7] * 10 }
768
+ end
769
+ end
770
+ allow(curriculum).to receive(:smoke_eval_set).and_return(
771
+ [{ signature: 'smoke_uname', prompt: 'uname' }]
772
+ )
773
+ g = curriculum.send(:ab_gate_v2, baseline: 'base', candidate: 'cand',
774
+ evalset: Array.new(10) { |i| { signature: "s#{i}", prompt: "p#{i}" } })
775
+ expect(g[:promote]).to be false
776
+ expect(g[:smoke_ok]).to be false
777
+ end
778
+ end
779
+
780
+ describe 'P12 · export_finetune quality gate' do
781
+ it 'drops low-score gold and compresses trajectories' do
782
+ good = PWN::Sessions.create(title: 'sft-good')
783
+ bad = PWN::Sessions.create(title: 'sft-bad')
784
+ [good, bad].each do |s|
785
+ PWN::Sessions.append(session_id: s[:id], role: 'user', content: 'scan lab')
786
+ PWN::Sessions.append(session_id: s[:id], role: 'tool', content: ok_trace)
787
+ PWN::Sessions.append(session_id: s[:id], role: 'assistant', content: '3 hosts up')
788
+ end
789
+ learning.note_outcome(task: 'scan lab', success: true, score: 0.9, session_id: good[:id])
790
+ learning.note_outcome(task: 'scan lab', success: true, score: 0.2, session_id: bad[:id])
791
+ info = learning.export_finetune(out: File.join(@tmp, 'sft-p12.jsonl'))
792
+ expect(info[:samples]).to eq 1
793
+ expect(info[:min_score]).to eq 0.6
794
+ expect(info[:compressed]).to be true
795
+ end
796
+ end
797
+
612
798
  describe 'P5 · export_dpo source-cap (no monoculture in weights)' do
613
799
  it 'downsamples so no single source exceeds DPO_SOURCE_CAP of the corpus' do
614
800
  10.times do |i|
615
- reward.record_preference(prompt: "p#{i}", rejected: "r#{i}", chosen: "c#{i}", source: :mistakes_resolve)
801
+ reward.record_preference(
802
+ prompt: "p#{i}", rejected: "r#{i}", chosen: "WINNING_TRACE:\nshell → ok #{i} " + ('x' * 40),
803
+ source: :mistakes_resolve, shape: :winning_trace, force: true
804
+ )
616
805
  end
617
806
  3.times do |i|
618
- reward.record_preference(prompt: "q#{i}", rejected: "x#{i}", chosen: "y#{i}", source: :counterfactual)
807
+ reward.record_preference(
808
+ prompt: "q#{i}", rejected: "x#{i}", chosen: "y#{i} " + ('alt ' * 20),
809
+ source: :counterfactual, shape: :real_dispatch, force: true
810
+ )
619
811
  end
620
812
  2.times do |i|
621
- reward.record_preference(prompt: "z#{i}", rejected: "a#{i}", chosen: "b#{i}", source: :critic)
813
+ reward.record_preference(
814
+ prompt: "z#{i}", rejected: "a#{i}", chosen: "REVISED ANSWER:\n#{'b' * 40}",
815
+ source: :critic, shape: :revised_answer, force: true
816
+ )
622
817
  end
623
818
  info = reward.export_dpo
624
819
  expect(info[:balanced]).to be true
@@ -627,8 +822,8 @@ RSpec.describe 'PWN::AI::Agent reinforced feedback loop', :aggregate_failures do
627
822
  info[:by_source].each_value do |n|
628
823
  expect(n.to_f / total).to be <= (reward::DPO_SOURCE_CAP + 0.05)
629
824
  end
630
- # raw dump still available for diagnostics
631
- raw = reward.export_dpo(balance: false, out: File.join(@tmp, 'raw-dpo.jsonl'))
825
+ # raw dump still available for diagnostics (no scrub, no balance)
826
+ raw = reward.export_dpo(balance: false, scrub: false, out: File.join(@tmp, 'raw-dpo.jsonl'))
632
827
  expect(raw[:pairs]).to eq 15
633
828
  expect(raw[:balanced]).to be false
634
829
  end
@@ -706,5 +901,604 @@ RSpec.describe 'PWN::AI::Agent reinforced feedback loop', :aggregate_failures do
706
901
  expect(out).to eq 'raw'
707
902
  end
708
903
  end
904
+
905
+ describe 'P14 · practice preference geometry' do
906
+ it 'records winning_trace curriculum pairs instead of fix prose' do
907
+ mistakes.record(tool: 'shell', error: 'p14-geometry-unique-zz')
908
+ allow(curriculum).to receive(:reflect_available?).and_return(false)
909
+ allow(curriculum).to receive(:self_play).and_return(
910
+ score: 0.9, verdict: :solved,
911
+ final: "fixed with nmap\nsecond line\nthird",
912
+ prompt: 'scan the lab safely',
913
+ trace: "shell → nmap -sn 10.0.0.0/24\nshell → true"
914
+ )
915
+ r = curriculum.practice(limit: 1, prompts_per: 2)
916
+ expect(r[:resolved]).to eq 1
917
+ pref = reward.preferences(source: 'curriculum').first
918
+ expect(pref[:shape].to_s).to eq 'winning_trace'
919
+ expect(pref[:chosen]).to include('WINNING_TRACE')
920
+ expect(pref[:chosen]).to include('nmap')
921
+ end
922
+ end
923
+
924
+ describe 'P15 · preference ledger hygiene' do
925
+ it 'usable_preference? drops CORRECTION prose and short resolve commentary' do
926
+ expect(reward.usable_preference?(
927
+ prompt: 'p', rejected: 'long ' * 80, chosen: 'CORRECTION: no', source: 'critic'
928
+ )).to be false
929
+ expect(reward.usable_preference?(
930
+ prompt: 'p', rejected: 'fail',
931
+ chosen: "STRATEGY: x\nWINNING_TRACE:\nshell → ok\n#{'x' * 80}",
932
+ source: 'curriculum', shape: 'winning_trace'
933
+ )).to be true
934
+ end
935
+
936
+ it 'scrub_preferences dry_run reports drops without rewriting' do
937
+ reward.record_preference(
938
+ prompt: 'p', rejected: 'bad final answer that is fairly long ' * 5,
939
+ chosen: 'CORRECTION: short', source: :critic, force: true
940
+ )
941
+ reward.record_preference(
942
+ prompt: 'q', rejected: 'old', chosen: "REVISED ANSWER:\n#{'ok ' * 40}",
943
+ source: :critic, shape: :revised_answer, force: true
944
+ )
945
+ r = reward.scrub_preferences(dry_run: true)
946
+ expect(r[:before]).to be >= 2
947
+ expect(r[:dropped]).to be >= 1
948
+ expect(r[:dry_run]).to be true
949
+ expect(reward.preferences.length).to eq r[:before]
950
+ end
951
+
952
+ it 'export_dpo scrub drops weak geometry before source cap' do
953
+ 5.times do |i|
954
+ reward.record_preference(
955
+ prompt: "p#{i}", rejected: 'long rejected final ' * 30,
956
+ chosen: 'CORRECTION: nope', source: :mistakes_resolve, force: true
957
+ )
958
+ end
959
+ 5.times do |i|
960
+ reward.record_preference(
961
+ prompt: "q#{i}", rejected: "r#{i}",
962
+ chosen: "WINNING_TRACE:\nshell → uname -r\n#{i} " + ('trace ' * 20),
963
+ source: :counterfactual, shape: :real_dispatch, force: true
964
+ )
965
+ end
966
+ info = reward.export_dpo(out: File.join(@tmp, 'p15-dpo.jsonl'))
967
+ expect(info[:geometry_dropped]).to be >= 5
968
+ expect(info[:scrubbed]).to be true
969
+ lines = File.readlines(info[:path]).map { |l| JSON.parse(l) }
970
+ expect(lines).not_to be_empty
971
+ expect(lines.any? { |l| l['chosen'].to_s.start_with?('CORRECTION:') }).to be false
972
+ end
973
+
974
+ it 'preference_balance(scrub: true) reports trajectory_fraction' do
975
+ reward.record_preference(
976
+ prompt: 'a', rejected: 'x', chosen: "WINNING_TRACE:\n#{'t' * 100}",
977
+ source: :curriculum, shape: :winning_trace, force: true
978
+ )
979
+ bal = reward.preference_balance(scrub: true)
980
+ expect(bal[:trajectory_fraction]).to be >= 0.0
981
+ expect(bal).to have_key(:by_shape)
982
+ expect(bal[:kept]).to be <= bal[:total]
983
+ end
984
+ end
985
+
986
+ describe 'P16 · warm_sentinel engages controllers' do
987
+ it 'fills to capacity and exposes proxy_distrust' do
988
+ 50.times do |i|
989
+ learning.note_outcome(task: "w#{i}", success: i.even?, score: i.even? ? 0.85 : 0.2,
990
+ details: 'x', tags: %w[rspec p16])
991
+ end
992
+ r = reward.warm_sentinel(limit: 120)
993
+ expect(r[:samples]).to be >= r[:added]
994
+ expect(r).to have_key(:proxy_distrust)
995
+ expect(%i[warmed_full warmed_partial full]).to include(r[:status])
996
+ end
997
+ end
998
+
999
+ describe 'P17 · budget-exhaustion curriculum target' do
1000
+ it 'natural_repro_prompts for agent_loop favour short-finish tasks' do
1001
+ prompts = curriculum.send(
1002
+ :natural_repro_prompts,
1003
+ mistake: { tool: 'agent_loop', error: 'iteration budget exhausted', shape: 'handler_error' },
1004
+ count: 4
1005
+ )
1006
+ expect(prompts.length).to eq 4
1007
+ blob = prompts.join(' ').downcase
1008
+ expect(blob).to match(/one shell|at most two|no tools|three iterations|uname|cwd/)
1009
+ end
1010
+
1011
+ it 'practice sorts budget fingerprints ahead of shell noise' do
1012
+ mistakes.record(tool: 'shell', error: 'shell-noise-unique-aaa')
1013
+ 3.times { mistakes.record(tool: 'agent_loop', error: 'iteration budget exhausted without a final answer') }
1014
+ allow(curriculum).to receive(:reflect_available?).and_return(false)
1015
+ allow(curriculum).to receive(:practice_skip?).and_return(false)
1016
+ seen = []
1017
+ allow(curriculum).to receive(:generate_reproducers) do |o|
1018
+ seen << o[:mistake][:tool]
1019
+ ['Answer in one shell call: print kernel release with uname -r']
1020
+ end
1021
+ curriculum.practice(limit: 1, prompts_per: 1, dry_run: true)
1022
+ expect(seen.first).to eq 'agent_loop'
1023
+ end
1024
+ end
1025
+
1026
+ describe 'P18 · PRM closes into Registry.rank' do
1027
+ it 'Metrics.record_step_reward shifts prm_advantage and rank scoring' do
1028
+ 10.times { metrics.record_step_reward(name: 'shell', reward: 1.0) }
1029
+ 10.times { metrics.record_step_reward(name: 'extro_rf_tune', reward: -1.0) }
1030
+ expect(metrics.prm_advantage(name: 'shell')).to be > metrics.prm_advantage(name: 'extro_rf_tune')
1031
+ ranked = registry.rank(query: 'run a shell command on the host')
1032
+ expect(ranked.first.name).to eq 'shell'
1033
+ end
1034
+ end
1035
+
1036
+ describe 'P19 · train_and_gate diet gate blocks promote on weak W1' do
1037
+ it 'preference_diet_gate fails when trajectory_fraction is low' do
1038
+ allow(reward).to receive(:preference_balance).and_return(
1039
+ total: 20, kept: 20, monoculture: true, fractions: { 'mistakes_resolve' => 0.9 },
1040
+ trajectory_fraction: 0.05, by_source: { 'mistakes_resolve' => 18 }
1041
+ )
1042
+ g = curriculum.send(:preference_diet_gate)
1043
+ expect(g[:ok]).to be false
1044
+ end
1045
+
1046
+ it 'preference_diet_gate passes on diverse trajectory diet' do
1047
+ allow(reward).to receive(:preference_balance).and_return(
1048
+ total: 40, kept: 40, monoculture: false,
1049
+ fractions: { 'curriculum' => 0.3, 'critic' => 0.3, 'counterfactual' => 0.25, 'mistakes_resolve' => 0.15 },
1050
+ trajectory_fraction: 0.55, by_source: { 'curriculum' => 12 }
1051
+ )
1052
+ g = curriculum.send(:preference_diet_gate)
1053
+ expect(g[:ok]).to be true
1054
+ expect(g[:reason]).to eq 'diet_ok'
1055
+ end
1056
+ end
1057
+
1058
+ describe 'P20 · judge-blended Metrics scalar' do
1059
+ it 'record_judge shifts effective_rate and advantage under distrust' do
1060
+ allow(reward).to receive(:proxy_distrust).and_return(0.8)
1061
+ 20.times { metrics.record(name: 'shell', success: true, duration: 0.1) }
1062
+ 20.times { metrics.record(name: 'extro_rf_tune', success: true, duration: 0.1) }
1063
+ 10.times { metrics.record_judge(name: 'shell', score: 0.9) }
1064
+ 10.times { metrics.record_judge(name: 'extro_rf_tune', score: 0.1) }
1065
+ expect(metrics.judge_rate(name: 'shell')).to be > 0.8
1066
+ expect(metrics.judge_rate(name: 'extro_rf_tune')).to be < 0.2
1067
+ expect(metrics.effective_rate(name: 'shell')).to be > metrics.effective_rate(name: 'extro_rf_tune')
1068
+ expect(metrics.advantage(name: 'shell')).to be > metrics.advantage(name: 'extro_rf_tune')
1069
+ end
1070
+
1071
+ it 'exemplars_for drops low-score success rows' do
1072
+ learning.note_outcome(task: 'p20 high judge shell uname', success: true, score: 0.95,
1073
+ details: 'ok', session_id: 'sess-p20-hi', tags: %w[rspec p20])
1074
+ learning.note_outcome(task: 'p20 low judge shell uname', success: true, score: 0.2,
1075
+ details: 'proxy lie', session_id: 'sess-p20-lo', tags: %w[rspec p20])
1076
+ # compress_exemplar needs real sessions — just ensure filter does not raise
1077
+ # and low score alone is excluded from pool scoring path
1078
+ pool = learning.outcomes(limit: 50, success: true)
1079
+ low = pool.select { |r| r[:score].to_f < 0.6 && r[:task].to_s.include?('p20 low') }
1080
+ expect(low).not_to be_empty
1081
+ # unit-check the filter predicate used in exemplars_for
1082
+ kept = pool.reject { |r| r.key?(:score) && r[:score].to_f < 0.6 }
1083
+ expect(kept.any? { |r| r[:task].to_s.include?('p20 low') }).to be false
1084
+ expect(kept.any? { |r| r[:task].to_s.include?('p20 high') }).to be true
1085
+ end
1086
+ end
1087
+
1088
+ describe 'P21/P25 · trajectory-only W1 writers' do
1089
+ it 'record_preference refuses non-trajectory shape without force' do
1090
+ r = reward.record_preference(
1091
+ prompt: 'p', rejected: 'bad', chosen: 'do this instead with more words here',
1092
+ source: :mistakes_resolve, shape: :fix_prose
1093
+ )
1094
+ expect(r).to be_a(Hash)
1095
+ expect(r[:skipped]).to eq :non_trajectory_shape
1096
+ end
1097
+
1098
+ it 'record_preference accepts winning_trace shape' do
1099
+ r = reward.record_preference(
1100
+ prompt: 'p', rejected: 'failing shell args',
1101
+ chosen: "STRATEGY: x\nWINNING_TRACE:\nshell → uname -r\n#{'t' * 40}",
1102
+ source: :mistakes_resolve, shape: :winning_trace
1103
+ )
1104
+ expect(r).to be_a(Hash)
1105
+ expect(r[:skipped]).to be_nil
1106
+ expect(r[:shape].to_s).to eq 'winning_trace'
1107
+ end
1108
+
1109
+ it 'mistakes.resolve does not write fix_prose preference pairs' do
1110
+ m = mistakes.record(tool: 'shell', error: 'p25-no-prose-unique-zz')
1111
+ before = reward.preferences.length
1112
+ mistakes.resolve(signature: m[:signature], fix: 'use a different flag next time')
1113
+ # no structured winning_trace → no new preference row
1114
+ expect(reward.preferences.length).to eq before
1115
+ end
1116
+
1117
+ it 'mistakes.resolve writes winning_trace when structured_fix has trace' do
1118
+ m = mistakes.record(tool: 'shell', error: 'p25-with-trace-unique-yy')
1119
+ before = reward.preferences.length
1120
+ # winning_trace must be ≥40 chars (P21 gate inside Mistakes.resolve)
1121
+ mistakes.resolve(
1122
+ signature: m[:signature],
1123
+ fix: 'run uname -r once',
1124
+ structured: {
1125
+ strategy: 'short_horizon_finish',
1126
+ tool: 'shell',
1127
+ winning_trace: "shell → uname -r\n6.19.14-kali\n(kernel release)"
1128
+ }
1129
+ )
1130
+ expect(reward.preferences.length).to be > before
1131
+ pref = reward.preferences(source: 'mistakes_resolve').find { |p| p[:chosen].to_s.include?('uname') }
1132
+ expect(pref).not_to be_nil
1133
+ expect(pref[:shape].to_s).to eq 'winning_trace'
1134
+ end
1135
+ end
1136
+
1137
+ describe 'P22 · W3 calibration lights up from plan_first' do
1138
+ it 'plan_first parse accepts p(success)= and stashes on Thread' do
1139
+ # Direct unit: emulate stash write path used by plan_first
1140
+ Thread.current[:pwn_plan_predicted] = 0.72
1141
+ expect(Thread.current[:pwn_plan_predicted]).to eq 0.72
1142
+ # calibrate path
1143
+ r = curriculum.calibrate(predicted: 0.72, actual: 1.0, engine: :ollama)
1144
+ expect(r[:brier]).to be_a(Numeric)
1145
+ expect(metrics.calibration(engine: :ollama)[:n]).to be >= 1
1146
+ Thread.current[:pwn_plan_predicted] = nil
1147
+ end
1148
+
1149
+ it 'recover_predicted_from_session reads Thread stash' do
1150
+ Thread.current[:pwn_plan_predicted] = 0.55
1151
+ pred = learning.send(:recover_predicted_from_session, session_id: 'missing')
1152
+ expect(pred).to eq 0.55
1153
+ Thread.current[:pwn_plan_predicted] = nil
1154
+ end
1155
+ end
1156
+
1157
+ describe 'P23 · short-horizon budget practice' do
1158
+ it 'self_play detects short-horizon prompt language' do
1159
+ # natural prompts already short-horizon; ensure generator still emits them
1160
+ prompts = curriculum.send(
1161
+ :natural_repro_prompts,
1162
+ mistake: { tool: 'agent_loop', error: 'iteration budget exhausted' },
1163
+ count: 3
1164
+ )
1165
+ expect(prompts.join(' ')).to match(/one shell|at most two|no tools|three iterations/i)
1166
+ end
1167
+
1168
+ it 'practice refuses resolve when holdouts ok but trace weak on budget target' do
1169
+ mistakes.record(tool: 'agent_loop', error: 'p23-weak-trace-budget-zz')
1170
+ allow(curriculum).to receive(:reflect_available?).and_return(false)
1171
+ allow(curriculum).to receive(:self_play).and_return(
1172
+ score: 0.9, verdict: :solved,
1173
+ final: 'x' * 2000, # long prose, no tool arrow
1174
+ prompt: 'Answer in one shell call: print kernel release with uname -r',
1175
+ trace: '' # empty trace
1176
+ )
1177
+ r = curriculum.practice(limit: 1, prompts_per: 2)
1178
+ row = r[:results]&.first || (r[:practiced] && nil)
1179
+ # practice returns practiced/resolved counts; weak trace should not resolve
1180
+ expect(r[:resolved]).to eq 0
1181
+ end
1182
+ end
1183
+
1184
+ describe 'P24 · critic cost capped under budget hot' do
1185
+ it 'critic(text_only: true) returns without persona tools' do
1186
+ allow(curriculum).to receive(:reflect_available?).and_return(false)
1187
+ r = curriculum.critic(
1188
+ request: 'what is kernel',
1189
+ final: '[pwn-ai] iteration budget exhausted',
1190
+ text_only: true
1191
+ )
1192
+ expect(r[:source].to_s).to include('text_only')
1193
+ expect(%i[pass flaw]).to include(r[:verdict])
1194
+ expect(r[:verdict]).to eq :flaw # heuristic sees budget exhausted
1195
+ end
1196
+
1197
+ it 'critic(text_only: true) does not require critic env flag' do
1198
+ # even when critic disabled, text_only path works (forced by budget hot)
1199
+ allow(curriculum).to receive(:enabled?).and_return(false)
1200
+ allow(curriculum).to receive(:reflect_available?).and_return(false)
1201
+ r = curriculum.critic(request: 'q', final: 'solid answer with facts', text_only: true)
1202
+ expect(r[:verdict]).to eq :pass
1203
+ end
1204
+ end
1205
+
1206
+ # ═══════════════════════════════════════════════════════════════════════
1207
+ # Design-priority STATUS (post P14–P25) — P0 / P1 / P2
1208
+ # documentation/Reinforcement-Learning.md § Design-priority STATUS
1209
+ # ═══════════════════════════════════════════════════════════════════════
1210
+
1211
+ describe 'P0 · W1 generator diversity (TARGET_SOURCE_MIX + generator_mix)' do
1212
+ it 'exposes TARGET_SOURCE_MIX soft targets that sum to 1.0' do
1213
+ mix = reward::TARGET_SOURCE_MIX
1214
+ expect(mix.keys).to include('mistakes_resolve', 'curriculum', 'counterfactual', 'critic', 'user_correction')
1215
+ expect(mix.values.sum).to be_within(0.001).of(1.0)
1216
+ expect(mix['mistakes_resolve']).to be <= 0.40
1217
+ end
1218
+
1219
+ it 'generator_mix flags underfilled sources and returns recommendation' do
1220
+ # seed a few usable traj pairs skewed toward curriculum/critic
1221
+ 6.times do |i|
1222
+ reward.record_preference(
1223
+ prompt: "p#{i}",
1224
+ rejected: "bad answer #{i} " + ('x' * 40),
1225
+ chosen: "STRATEGY: use shell\nWINNING_TRACE: shell(uname)\nFINAL: linux #{i}",
1226
+ source: i.even? ? :curriculum : :critic,
1227
+ shape: :winning_trace
1228
+ )
1229
+ end
1230
+ m = reward.generator_mix
1231
+ expect(m[:n]).to be >= 5
1232
+ expect(m[:shares]).to be_a(Hash)
1233
+ expect(m[:targets]).to eq(reward::TARGET_SOURCE_MIX)
1234
+ expect(m[:urgent]).to include('counterfactual') # 0 share << 0.20 target
1235
+ expect(m[:healthy]).to eq(false)
1236
+ expect(m[:recommendation]).to match(/boost:|need_/)
1237
+ end
1238
+
1239
+ it 'write_source_quota reports underfilled against TARGET_SOURCE_MIX' do
1240
+ 12.times do |i|
1241
+ reward.record_preference(
1242
+ prompt: "q#{i}",
1243
+ rejected: "nope #{'y' * 40}",
1244
+ chosen: "STRATEGY: a\nWINNING_TRACE: t\nFINAL: ok #{i}",
1245
+ source: :curriculum,
1246
+ shape: :winning_trace
1247
+ )
1248
+ end
1249
+ q = reward.write_source_quota(source: 'counterfactual')
1250
+ expect(q[:underfilled]).to eq(true)
1251
+ expect(q[:target]).to eq(0.20)
1252
+ end
1253
+
1254
+ it 'counterfactual and critic force-run when generator_mix marks them urgent' do
1255
+ allow(reward).to receive(:generator_mix).and_return(
1256
+ urgent: %w[counterfactual critic], suppress: [], healthy: false, n: 20,
1257
+ recommendation: 'boost:counterfactual,critic'
1258
+ )
1259
+ # env flags OFF — mix_need should still open the gate
1260
+ @agent_cfg[:counterfactual] = false
1261
+ @agent_cfg[:critic] = false
1262
+ allow(curriculum).to receive(:in_curriculum?).and_return(false)
1263
+ allow(curriculum).to receive(:reflect_available?).and_return(false)
1264
+
1265
+ # counterfactual: stubs the expensive branch; we only assert it does not
1266
+ # short-circuit on disabled when mix_need is true. If still nil for other
1267
+ # reasons, at least enabled? gate is not the cause.
1268
+ allow(curriculum).to receive(:enabled?).and_call_original
1269
+ # critic text_only always works once gate opens
1270
+ r = curriculum.critic(request: 'q', final: 'a solid factual answer', text_only: true)
1271
+ expect(r[:source].to_s).not_to eq('disabled')
1272
+ end
1273
+ end
1274
+
1275
+ describe 'P0 · Introspect budget (soft/hard ms + stages_skipped)' do
1276
+ it 'defines INTROSPECT_SOFT_MS < INTROSPECT_HARD_MS' do
1277
+ expect(learning::INTROSPECT_SOFT_MS).to be < learning::INTROSPECT_HARD_MS
1278
+ expect(learning::INTROSPECT_SOFT_MS).to be_between(500, 10_000)
1279
+ end
1280
+
1281
+ it 'auto_introspect returns stages_skipped and respects hard budget' do
1282
+ @agent_cfg[:auto_introspect] = true
1283
+ # Force over_hard by collapsing thresholds
1284
+ stub_const('PWN::AI::Agent::Learning::INTROSPECT_SOFT_MS', 0)
1285
+ stub_const('PWN::AI::Agent::Learning::INTROSPECT_HARD_MS', 0)
1286
+ allow(loop_mod).to receive(:budget_exhaustion_hot?).and_return(false)
1287
+
1288
+ sid = 'spec_budget_introspect'
1289
+ dir = File.join(@tmp, 'sessions')
1290
+ FileUtils.mkdir_p(dir)
1291
+ payload = [
1292
+ { role: 'user', content: 'uname -a', timestamp: Time.now.utc.iso8601 },
1293
+ { role: 'assistant', content: 'Linux kali', timestamp: Time.now.utc.iso8601 }
1294
+ ].map { |h| JSON.generate(h) }.join("\n")
1295
+ File.write(File.join(dir, "#{sid}.jsonl"), "#{payload}\n")
1296
+
1297
+ r = learning.auto_introspect(session_id: sid, request: 'uname -a', final: 'Linux kali')
1298
+ expect(r).to be_a(Hash)
1299
+ expect(r).to have_key(:stages_run)
1300
+ expect(r).to have_key(:stages_skipped)
1301
+ expect(r[:stages_skipped]).to include(:prm) # hard skip
1302
+ expect(r[:elapsed_ms]).to be_a(Numeric)
1303
+ end
1304
+ end
1305
+
1306
+ describe 'P1 · Local judge calibration (shrinkage + confidence)' do
1307
+ it 'heuristic judge returns confidence and shrinks extreme local scores' do
1308
+ v = reward.judge(
1309
+ request: 'what is the kernel',
1310
+ final: 'The Linux kernel is a monolithic unix-like kernel used by kali.',
1311
+ trace: [],
1312
+ proxy_ok: true,
1313
+ commit: false
1314
+ )
1315
+ expect(v[:source].to_s).to eq('heuristic')
1316
+ expect(v[:confidence]).to be <= 0.5
1317
+ expect(v).to have_key(:score_raw)
1318
+ # local + no-trace cap pulls highs down
1319
+ expect(v[:score]).to be <= 0.45 if v[:score_raw].to_f >= 0.6
1320
+ expect(v[:rationale].to_s).to match(/P1:local_no_trace_cap|heuristic/)
1321
+ end
1322
+
1323
+ it 'Metrics.effective_rate scales distrust by judge_confidence' do
1324
+ # establish proxy success
1325
+ 10.times { metrics.record(name: 'shell', success: true, duration: 0.01) }
1326
+ # high judge scores but low confidence (heuristic)
1327
+ 8.times { metrics.record_judge(name: 'shell', score: 0.2, confidence: 0.35) }
1328
+ allow(metrics).to receive(:proxy_trust).and_return(0.3) # distrust 0.7
1329
+ rate = metrics.effective_rate(name: 'shell')
1330
+ conf = metrics.judge_confidence(name: 'shell')
1331
+ expect(conf).to be_within(0.05).of(0.35)
1332
+ # With conf=0.35, eff_d = 0.7*0.35 = 0.245 — still blended, not pure judge
1333
+ # pure judge would be ~0.2; pure proxy ~1.0; blend must sit between
1334
+ expect(rate).to be > 0.2
1335
+ expect(rate).to be < 1.0
1336
+ end
1337
+ end
1338
+
1339
+ describe 'P1 · Practice outer KPI (practice_kpi + repeating_trend)' do
1340
+ it 'practice_kpi snapshots repeating counts and writes KPI_FILE' do
1341
+ mistakes.record(tool: 'agent_loop', error: 'iteration budget exhausted without a final answer')
1342
+ 3.times { mistakes.record(tool: 'agent_loop', error: 'iteration budget exhausted without a final answer') }
1343
+ r = curriculum.practice_kpi(results: [{ resolved: true, mean_score: 0.8 }])
1344
+ expect(r[:repeating_n]).to be >= 1
1345
+ expect(r[:budget_repeating_n]).to be >= 1
1346
+ expect(r[:practiced]).to eq 1 # len(results) recorded tonight
1347
+ expect(r[:resolved_tonight]).to eq 1
1348
+ expect(File.exist?(curriculum::KPI_FILE)).to eq(true)
1349
+ expect(r[:trend]).to be_a(Hash)
1350
+ end
1351
+
1352
+ it 'repeating_trend reports baseline then delta across snapshots' do
1353
+ curriculum.practice_kpi(results: [])
1354
+ # bump repeating by recording more
1355
+ 4.times { mistakes.record(tool: 'assistant_answer', error: 'critic: [pwn-ai] iteration budget exhausted') }
1356
+ curriculum.practice_kpi(results: [])
1357
+ t = curriculum.repeating_trend
1358
+ expect(t[:samples]).to be >= 2
1359
+ expect(t).to have_key(:delta_repeating)
1360
+ expect(%i[improving flat regressing baseline]).to include(t[:status])
1361
+ end
1362
+ end
1363
+
1364
+ describe 'P2 · PRM sample efficiency (PRM_MIN_N / fleet coverage)' do
1365
+ it 'prm_advantage is 0 until n >= PRM_MIN_N then applies shrinkage' do
1366
+ expect(metrics::PRM_MIN_N).to eq 5
1367
+ expect(metrics::PRM_FULL_N).to eq 20
1368
+ 4.times { metrics.record_step_reward(name: 'shell', reward: 1.0) }
1369
+ expect(metrics.prm_n(name: 'shell')).to eq 4
1370
+ expect(metrics.prm_advantage(name: 'shell')).to eq 0.0
1371
+ metrics.record_step_reward(name: 'shell', reward: 1.0)
1372
+ expect(metrics.prm_n(name: 'shell')).to eq 5
1373
+ # single-tool zero-variance → damp*shrink keeps |adv| small but defined
1374
+ adv = metrics.prm_advantage(name: 'shell')
1375
+ expect(adv).to be_a(Numeric)
1376
+ end
1377
+
1378
+ it 'Registry.rank drops PRM delta until ≥3 tools are PRM-ready' do
1379
+ # only 1 tool ready → delta 0; rank still returns by keyword
1380
+ 10.times { metrics.record_step_reward(name: 'shell', reward: 1.0) }
1381
+ expect(metrics.prm_n(name: 'shell')).to be >= metrics::PRM_MIN_N
1382
+ ranked = registry.rank(query: 'run a shell command on the host')
1383
+ expect(ranked.map(&:name)).to include('shell')
1384
+ # spin up 3 ready tools — rank path must not raise
1385
+ %w[shell memory_recall sessions_list].each do |n|
1386
+ 10.times { metrics.record_step_reward(name: n, reward: 0.5) }
1387
+ end
1388
+ expect do
1389
+ registry.rank(query: 'run a shell command on the host')
1390
+ end.not_to raise_error
1391
+ end
1392
+ end
1393
+
1394
+ describe 'P2 · STATUS table is the flag authority (no archaeology)' do
1395
+ it 'Reinforcement-Learning.md ships a Design-priority STATUS section' do
1396
+ doc_path = File.expand_path('../../documentation/Reinforcement-Learning.md', __dir__)
1397
+ expect(File.exist?(doc_path)).to eq(true), "missing #{doc_path}"
1398
+ doc = File.read(doc_path)
1399
+ expect(doc).to match(/Design-priority STATUS/)
1400
+ expect(doc).to match(/generator_mix/)
1401
+ expect(doc).to match(/INTROSPECT_SOFT_MS/)
1402
+ expect(doc).to match(/practice_kpi/)
1403
+ expect(doc).to match(/PRM_MIN_N/)
1404
+ end
1405
+ end
1406
+
1407
+ # Design-priority ops closure — make STATUS success criteria reachable on-host
1408
+ describe 'P0 ops · nightly diet close + shape backfill + mix in prompt' do
1409
+ it 'infer_shape tags legacy winning_trace-like chosen sides' do
1410
+ row = {
1411
+ source: 'mistakes_resolve',
1412
+ chosen: "shell → {\"success\":true}\npwn_eval → ok\n#{'x' * 80}",
1413
+ rejected: "failed earlier #{'y' * 80}",
1414
+ shape: ''
1415
+ }
1416
+ s = reward.infer_shape(row: row)
1417
+ expect(s).to eq('winning_trace')
1418
+ end
1419
+
1420
+ it 'infer_shape tags long critic revisions as revised_answer' do
1421
+ row = {
1422
+ source: 'critic',
1423
+ chosen: 'A' * 250,
1424
+ rejected: 'B' * 100,
1425
+ shape: nil
1426
+ }
1427
+ expect(reward.infer_shape(row: row)).to eq('revised_answer')
1428
+ end
1429
+
1430
+ it 'scrub_preferences backfills shape on kept rows when dry_run:false path runs over tmp' do
1431
+ # exercise infer_shape + usable path without rewriting the live ledger
1432
+ raw = {
1433
+ source: 'curriculum',
1434
+ prompt: 'finish under 5 tools',
1435
+ rejected: "iteration budget exhausted #{'z' * 80}",
1436
+ chosen: "shell → ok\nmemory_recall → hit\n#{'w' * 100}",
1437
+ shape: ''
1438
+ }
1439
+ expect(reward.usable_preference?(row: raw.merge(shape: 'winning_trace'))).to eq(true)
1440
+ inferred = reward.infer_shape(row: raw)
1441
+ expect(reward::TRAJECTORY_SHAPES).to include(inferred)
1442
+ end
1443
+
1444
+ it 'offline_judge return hash reserves scrub/generator_mix/practice_kpi keys' do
1445
+ # dry structural contract: method source must call scrub + mix + kpi
1446
+ src = begin
1447
+ File.read(curriculum.instance_method(:offline_judge).source_location.first)
1448
+ rescue StandardError
1449
+ File.read('lib/pwn/ai/agent/curriculum.rb')
1450
+ end
1451
+ expect(src).to match(/scrub_preferences/)
1452
+ expect(src).to match(/generator_mix/)
1453
+ expect(src).to match(/practice_kpi/)
1454
+ end
1455
+
1456
+ it 'Metrics.to_context surfaces W1 MIX when generator_mix is unhealthy' do
1457
+ allow(reward).to receive(:generator_mix).and_return(
1458
+ n: 20, healthy: false, trajectory_fraction: 0.1,
1459
+ urgent: %w[critic counterfactual], suppress: %w[mistakes_resolve],
1460
+ recommendation: 'boost:critic,counterfactual'
1461
+ )
1462
+ # ensure at least one metrics row so to_context is non-empty
1463
+ metrics.record(name: 'shell', success: true, duration: 0.01) if metrics.respond_to?(:record)
1464
+ ctx = metrics.to_context(limit: 3)
1465
+ expect(ctx).to match(/W1 MIX:/)
1466
+ expect(ctx).to match(/boost:critic/)
1467
+ end
1468
+ end
1469
+
1470
+ describe 'P0 · Budget exhaust deepen (last-iter / no-CF-hot / exhaust Learning)' do
1471
+ it 'tightens budget-hot max_iters caps to 8 local / 12 remote' do
1472
+ src = File.read(loop_mod.method(:run).source_location.first)
1473
+ # max_iters is private_class_method — read surrounding source
1474
+ expect(src).to match(/ollama \? 8 : 12/)
1475
+ expect(src).to match(/budget_exhaustion_hot\?/)
1476
+ end
1477
+
1478
+ it 'forces tools=nil on last iter and skips counterfactual when budget-hot' do
1479
+ src = File.read(loop_mod.method(:run).source_location.first)
1480
+ expect(src).to match(/FINAL ITERATION/)
1481
+ expect(src).to match(/last_iter \? nil : tools/)
1482
+ # CF gate requires !budget_exhaustion_hot?
1483
+ expect(src).to match(/!budget_exhaustion_hot\?/)
1484
+ expect(src).to match(/Curriculum\.counterfactual/)
1485
+ end
1486
+
1487
+ it 'exhaust path appends session + auto_introspect (not bare string only)' do
1488
+ src = File.read(loop_mod.method(:run).source_location.first)
1489
+ idx = src.index('shape: :budget_exhausted')
1490
+ expect(idx).not_to be_nil
1491
+ tail = src[idx, 800]
1492
+ expect(tail).to match(/append_session/)
1493
+ expect(tail).to match(/auto_introspect/)
1494
+ expect(tail).to match(/final_msg/)
1495
+ end
1496
+
1497
+ it 'STATUS table lists Budget exhaust deepen' do
1498
+ doc = File.read(File.expand_path('../../documentation/Reinforcement-Learning.md', __dir__))
1499
+ expect(doc).to match(/Budget exhaust deepen/)
1500
+ expect(doc).to match(/Last-iter force-final/)
1501
+ end
1502
+ end
709
1503
  end
710
1504
  # rubocop:enable Metrics/BlockLength