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.
- checksums.yaml +4 -4
- data/documentation/Agent-Tool-Registry.md +10 -5
- data/documentation/Cron.md +3 -2
- data/documentation/Home.md +2 -2
- data/documentation/How-PWN-Works.md +1 -1
- data/documentation/Mistakes.md +13 -0
- data/documentation/Reinforcement-Learning.md +72 -3
- data/documentation/Skills-Memory-Learning.md +5 -3
- data/documentation/What-is-PWN.md +1 -1
- data/documentation/diagrams/dot/pwn-ai-feedback-learning-loop.dot +3 -3
- data/documentation/diagrams/dot/reinforcement-learning.dot +8 -8
- data/documentation/diagrams/pwn-ai-feedback-learning-loop.svg +318 -319
- data/documentation/diagrams/reinforcement-learning.svg +188 -187
- data/documentation/pwn-ai-Agent.md +6 -5
- data/lib/pwn/ai/agent/curriculum.rb +491 -35
- data/lib/pwn/ai/agent/extrospection.rb +18 -4
- data/lib/pwn/ai/agent/learning.rb +308 -54
- data/lib/pwn/ai/agent/loop.rb +145 -11
- data/lib/pwn/ai/agent/metrics.rb +200 -11
- data/lib/pwn/ai/agent/mistakes.rb +26 -8
- data/lib/pwn/ai/agent/registry.rb +24 -2
- data/lib/pwn/ai/agent/reward.rb +447 -7
- data/lib/pwn/ai/agent/tools/curriculum.rb +23 -0
- data/lib/pwn/ai/agent/tools/reward.rb +86 -0
- data/lib/pwn/ai/agent/tools/ruby_eval.rb +29 -6
- data/lib/pwn/cron.rb +14 -2
- data/lib/pwn/version.rb +1 -1
- data/spec/integration/reinforced_feedback_loop_spec.rb +806 -12
- data/spec/lib/pwn/ai/agent/reward_spec.rb +64 -1
- data/spec/lib/pwn/ai/agent/tools/ruby_eval_spec.rb +39 -1
- data/third_party/pwn_rdoc.jsonl +33 -4
- metadata +1 -1
|
@@ -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:
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
472
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|