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