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
|
@@ -118,10 +118,11 @@ full `Loop.run` under a persona overlay) that share a JSONL bus. See
|
|
|
118
118
|
local.
|
|
119
119
|
- `PWN::AI::Agent::Learning.export_finetune` + `Reward.export_dpo` turn every
|
|
120
120
|
successful session and every preference pair into supervised / DPO
|
|
121
|
-
datasets under `~/.pwn/finetune/`
|
|
122
|
-
LoRA-tunes the local model
|
|
123
|
-
|
|
124
|
-
|
|
121
|
+
datasets under `~/.pwn/finetune/` — `Curriculum.train_and_gate` then
|
|
122
|
+
LoRA-tunes the local model and promotes only under **gate v2** (resolved
|
|
123
|
+
margin + mean judge + frozen smoke set). Preference pairs use trajectory
|
|
124
|
+
geometry (P9/P14/P15): revised answers / winning traces, not `CORRECTION:` prose; `scrub_preferences` + export filter; practice lands `shape: :winning_trace`.
|
|
125
|
+
See [Reinforcement Learning](Reinforcement-Learning.md).
|
|
125
126
|
|
|
126
127
|
## RL feature flags (`PWN::Env[:ai][:agent]`)
|
|
127
128
|
|
|
@@ -94,6 +94,15 @@ module PWN
|
|
|
94
94
|
[]
|
|
95
95
|
end
|
|
96
96
|
cool = load_cooldown
|
|
97
|
+
# P17 — prefer budget-exhaustion fingerprints (agent_loop / critic)
|
|
98
|
+
# so nightly self-play attacks the #1 live skill gap first.
|
|
99
|
+
candidates = candidates.sort_by do |m|
|
|
100
|
+
t = m[:tool].to_s
|
|
101
|
+
e = m[:error].to_s.downcase
|
|
102
|
+
budget = t == 'agent_loop' || t == 'assistant_answer' ||
|
|
103
|
+
e.include?('budget exhausted') || e.include?('iteration budget')
|
|
104
|
+
[budget ? 0 : 1, -m[:count].to_i]
|
|
105
|
+
end
|
|
97
106
|
targets = candidates.reject { |m| practice_skip?(mistake: m, cooldown: cool) }.first(limit)
|
|
98
107
|
results = []
|
|
99
108
|
|
|
@@ -107,20 +116,68 @@ module PWN
|
|
|
107
116
|
mean = runs.empty? ? 0.0 : (runs.sum { |r| r[:score].to_f } / runs.length)
|
|
108
117
|
resolved = false
|
|
109
118
|
# 2.4 — auto-resolve only with N≥2 holdout successes + store trace
|
|
119
|
+
# P23 — auto-resolve only with N≥2 holdouts at judge≥0.7 AND a
|
|
120
|
+
# real tool trace (not empty-final luck). Budget fingerprints
|
|
121
|
+
# additionally require mean holdout ≥0.7 and short-horizon tags.
|
|
110
122
|
if solved.length >= 2 && defined?(Mistakes)
|
|
111
123
|
best = solved.max_by { |r| r[:score] }
|
|
124
|
+
winning = best[:trace].to_s.strip
|
|
125
|
+
winning = best[:final].to_s.strip if winning.length < 20
|
|
126
|
+
budgetish = %w[agent_loop assistant_answer].include?(m[:tool].to_s) ||
|
|
127
|
+
m[:error].to_s.downcase.include?('budget')
|
|
128
|
+
# refuse resolve on budget targets when winning_trace is prose-only
|
|
129
|
+
trace_ok = winning.length >= 20 && (
|
|
130
|
+
!budgetish || winning.match?(/→|shell|pwn_eval|tool/i) || best[:final].to_s.length.between?(1, 800)
|
|
131
|
+
)
|
|
132
|
+
unless trace_ok
|
|
133
|
+
bump_cooldown!(cooldown: cool, signature: m[:signature], mean: mean) unless dry_run
|
|
134
|
+
results << {
|
|
135
|
+
signature: m[:signature], tool: m[:tool], prompts: prompts,
|
|
136
|
+
runs: runs.map { |r| { score: r[:score], verdict: r[:verdict] } },
|
|
137
|
+
resolved: false, mean_score: mean.round(3),
|
|
138
|
+
reason: 'holdouts_ok_but_trace_weak'
|
|
139
|
+
}
|
|
140
|
+
next
|
|
141
|
+
end
|
|
112
142
|
fix = best[:final].to_s.lines.first(3).join.strip[0, 400]
|
|
113
143
|
Mistakes.resolve(
|
|
114
144
|
signature: m[:signature],
|
|
115
145
|
fix: "auto-curriculum: #{fix}",
|
|
116
146
|
structured: {
|
|
117
|
-
strategy: 'curriculum_practice',
|
|
147
|
+
strategy: budgetish ? 'short_horizon_finish' : 'curriculum_practice',
|
|
118
148
|
tool: m[:tool],
|
|
119
149
|
holdout_tests: solved.map { |r| r[:prompt] || r[:request] }.compact.first(5),
|
|
120
|
-
winning_trace:
|
|
150
|
+
winning_trace: winning[0, 2_000]
|
|
121
151
|
}
|
|
122
152
|
)
|
|
123
|
-
|
|
153
|
+
# P14 — trajectory-shaped curriculum pair (not first-3-lines fix prose).
|
|
154
|
+
# Mistakes.resolve also lands a mistakes_resolve pair via winning_trace;
|
|
155
|
+
# this :curriculum row keeps W1 source diversity honest.
|
|
156
|
+
if defined?(Reward)
|
|
157
|
+
rejected = m[:snippet].to_s
|
|
158
|
+
rejected = "FAILING: tool=#{m[:tool]} err=#{m[:error]}" if rejected.strip.empty?
|
|
159
|
+
chosen = if best[:trace].to_s.strip.length >= 20
|
|
160
|
+
parts = []
|
|
161
|
+
parts << "STRATEGY: curriculum_practice | #{m[:tool]}"
|
|
162
|
+
parts << "WINNING_TRACE:\n#{best[:trace].to_s[0, 3_000]}"
|
|
163
|
+
parts << "FINAL:\n#{best[:final].to_s[0, 800]}" unless best[:final].to_s.strip.empty?
|
|
164
|
+
parts.join("\n")
|
|
165
|
+
else
|
|
166
|
+
best[:final].to_s[0, 3_500]
|
|
167
|
+
end
|
|
168
|
+
Reward.record_preference(
|
|
169
|
+
prompt: (best[:prompt] || prompts.first).to_s,
|
|
170
|
+
rejected: rejected[0, 2_000],
|
|
171
|
+
chosen: chosen,
|
|
172
|
+
source: :curriculum,
|
|
173
|
+
shape: :winning_trace,
|
|
174
|
+
meta: {
|
|
175
|
+
signature: m[:signature],
|
|
176
|
+
score: best[:score],
|
|
177
|
+
holdouts: solved.length
|
|
178
|
+
}
|
|
179
|
+
)
|
|
180
|
+
end
|
|
124
181
|
resolved = true
|
|
125
182
|
cool.delete(m[:signature].to_s)
|
|
126
183
|
elsif !dry_run
|
|
@@ -245,7 +302,12 @@ module PWN
|
|
|
245
302
|
end
|
|
246
303
|
|
|
247
304
|
mean = scored.empty? ? nil : (scored.sum { |r| r[:score].to_f } / scored.length).round(3)
|
|
248
|
-
|
|
305
|
+
# P10 — keep R3 window warm so proxy_distrust can engage on local hosts
|
|
306
|
+
warm = (Reward.warm_sentinel(limit: 120) if commit && defined?(Reward) && Reward.respond_to?(:warm_sentinel))
|
|
307
|
+
out = {
|
|
308
|
+
scored: scored.length, mean: mean, since_hours: since_h,
|
|
309
|
+
results: scored.first(10), sentinel_warm: warm
|
|
310
|
+
}
|
|
249
311
|
log(event: :offline_judge, data: out)
|
|
250
312
|
out
|
|
251
313
|
rescue StandardError => e
|
|
@@ -257,6 +319,14 @@ module PWN
|
|
|
257
319
|
public_class_method def self.preference_balance(opts = {})
|
|
258
320
|
return { total: 0 } unless defined?(Reward)
|
|
259
321
|
|
|
322
|
+
# P15 — prefer Reward.preference_balance (geometry-aware + optional scrub).
|
|
323
|
+
if Reward.respond_to?(:preference_balance)
|
|
324
|
+
return Reward.preference_balance(
|
|
325
|
+
limit: opts[:limit] || 10_000,
|
|
326
|
+
scrub: opts.key?(:scrub) ? opts[:scrub] : false
|
|
327
|
+
)
|
|
328
|
+
end
|
|
329
|
+
|
|
260
330
|
rows = Reward.preferences(limit: opts[:limit] || 10_000)
|
|
261
331
|
by = Hash.new(0)
|
|
262
332
|
rows.each { |r| by[r[:source].to_s] += 1 }
|
|
@@ -292,12 +362,39 @@ module PWN
|
|
|
292
362
|
end
|
|
293
363
|
return nil if branch_b.to_s.strip.empty?
|
|
294
364
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
365
|
+
sa_h = score_branch_detailed(request: request, branch: branch_a)
|
|
366
|
+
sb_h = score_branch_detailed(request: request, branch: branch_b)
|
|
367
|
+
sa = sa_h[:score]
|
|
368
|
+
sb = sb_h[:score]
|
|
369
|
+
if sb > sa
|
|
370
|
+
winner = branch_b
|
|
371
|
+
loser = branch_a
|
|
372
|
+
tag = :b
|
|
373
|
+
wmeta = sb_h
|
|
374
|
+
else
|
|
375
|
+
winner = branch_a
|
|
376
|
+
loser = branch_b
|
|
377
|
+
tag = :a
|
|
378
|
+
wmeta = sa_h
|
|
379
|
+
end
|
|
380
|
+
real_hit = sa_h[:mode] == :real_dispatch || sb_h[:mode] == :real_dispatch
|
|
381
|
+
shape = real_hit ? :real_dispatch : :imagined
|
|
382
|
+
if defined?(Reward)
|
|
383
|
+
Reward.record_preference(
|
|
384
|
+
prompt: "#{request} | failing: #{opts[:name]} → #{opts[:error]}",
|
|
385
|
+
rejected: loser.to_s[0, 2_000],
|
|
386
|
+
chosen: winner.to_s[0, 2_000],
|
|
387
|
+
source: :counterfactual,
|
|
388
|
+
shape: shape,
|
|
389
|
+
meta: {
|
|
390
|
+
a_score: sa, b_score: sb,
|
|
391
|
+
a_mode: sa_h[:mode], b_mode: sb_h[:mode],
|
|
392
|
+
winner_trace: wmeta[:trace].to_s[0, 500]
|
|
393
|
+
}
|
|
394
|
+
)
|
|
395
|
+
end
|
|
396
|
+
log(event: :counterfactual, data: { branch: tag, a: sa, b: sb, tool: opts[:name].to_s, shape: shape })
|
|
397
|
+
{ branch: tag, content: winner, score: [sa, sb].max, a: sa, b: sb, shape: shape, a_mode: sa_h[:mode], b_mode: sb_h[:mode] }
|
|
301
398
|
rescue StandardError => e
|
|
302
399
|
warn "[pwn-ai/curriculum] counterfactual swallowed: #{e.class}: #{e.message}"
|
|
303
400
|
nil
|
|
@@ -319,9 +416,14 @@ module PWN
|
|
|
319
416
|
# self-correction becomes DPO signal.
|
|
320
417
|
|
|
321
418
|
public_class_method def self.critic(opts = {})
|
|
322
|
-
return { verdict: :pass, source: :disabled } unless enabled?(key: :critic)
|
|
419
|
+
return { verdict: :pass, source: :disabled } unless enabled?(key: :critic) || opts[:text_only]
|
|
323
420
|
return { verdict: :pass, source: :recursion } if in_curriculum?
|
|
324
421
|
|
|
422
|
+
# P24 — text_only: single Reflect shot, no tool-armed persona swarm.
|
|
423
|
+
# Used when budget_exhaustion_hot? so critic cannot thrash the budget
|
|
424
|
+
# that auto_introspect is trying to protect.
|
|
425
|
+
return critic_text_only(request: opts[:request], final: opts[:final], session_id: opts[:session_id]) if opts[:text_only]
|
|
426
|
+
|
|
325
427
|
ensure_persona(name: CRITIC_NAME, role: "You are pwn-ai's constitutional critic. Given a REQUEST and a candidate ANSWER, find ONE concrete, verifiable flaw (wrong fact, missing step, unsupported claim, broken command). You MAY call shell / extro_verify / pwn_eval to check. If none found reply exactly: PASS. Otherwise reply: FLAW: <one line>.")
|
|
326
428
|
reply = with_curriculum_guard do
|
|
327
429
|
ask_persona(name: CRITIC_NAME, request: "REQUEST:\n#{opts[:request].to_s[0, 800]}\n\nANSWER:\n#{opts[:final].to_s[0, 2_000]}")
|
|
@@ -332,13 +434,22 @@ module PWN
|
|
|
332
434
|
else
|
|
333
435
|
flaw = reply.to_s.sub(/\AFLAW:\s*/i, '').strip[0, 300]
|
|
334
436
|
Mistakes.record(tool: 'assistant_answer', error: "critic: #{flaw}", args: opts[:final].to_s[0, 200], session_id: opts[:session_id], source: :model) if defined?(Mistakes)
|
|
335
|
-
#
|
|
437
|
+
# P9 — DPO pair geometry: rejected=bad final, chosen=REVISED full
|
|
438
|
+
# answer (not "CORRECTION: flaw" prose). Prefer persona rewrite.
|
|
336
439
|
if defined?(Reward) && !flaw.to_s.empty?
|
|
440
|
+
revised = revise_after_flaw(
|
|
441
|
+
request: opts[:request],
|
|
442
|
+
final: opts[:final],
|
|
443
|
+
flaw: flaw,
|
|
444
|
+
session_id: opts[:session_id]
|
|
445
|
+
)
|
|
337
446
|
Reward.record_preference(
|
|
338
447
|
prompt: opts[:request].to_s[0, 1_000],
|
|
339
448
|
rejected: opts[:final].to_s[0, 2_000],
|
|
340
|
-
chosen:
|
|
341
|
-
source: :critic
|
|
449
|
+
chosen: revised,
|
|
450
|
+
source: :critic,
|
|
451
|
+
shape: :revised_answer,
|
|
452
|
+
meta: { flaw: flaw.to_s[0, 200] }
|
|
342
453
|
)
|
|
343
454
|
end
|
|
344
455
|
log(event: :critic, data: { verdict: :flaw, flaw: flaw.to_s[0, 200] })
|
|
@@ -462,8 +573,15 @@ module PWN
|
|
|
462
573
|
|
|
463
574
|
candidate = ollama_create(base: base, adapter: adapter, version: version)
|
|
464
575
|
baseline = state[:tag] || base
|
|
465
|
-
|
|
466
|
-
|
|
576
|
+
# P11 — gate v2: resolved delta + mean judge + frozen smoke set.
|
|
577
|
+
gate = ab_gate_v2(baseline: baseline, candidate: candidate, evalset: evalset)
|
|
578
|
+
# P19 — refuse promote when W1 diet is still prose/monoculture.
|
|
579
|
+
# Export-only is correct until scrubbed pairs show trajectory diversity.
|
|
580
|
+
diet = preference_diet_gate
|
|
581
|
+
gate = gate.merge(preference_diet: diet)
|
|
582
|
+
promoted = gate[:promote] == true && diet[:ok] == true
|
|
583
|
+
gate[:promote] = promoted
|
|
584
|
+
gate[:promote_blocked_by_diet] = true unless diet[:ok]
|
|
467
585
|
if promoted
|
|
468
586
|
state[:previous] = state[:tag]
|
|
469
587
|
state[:tag] = candidate
|
|
@@ -472,7 +590,7 @@ module PWN
|
|
|
472
590
|
state[:gate] = gate
|
|
473
591
|
save_models(state: state)
|
|
474
592
|
end
|
|
475
|
-
result.merge(adapter: adapter, candidate: candidate, gate: gate, promoted: promoted)
|
|
593
|
+
result.merge(adapter: adapter, candidate: candidate, gate: gate, promoted: promoted, weight_loop: :closed)
|
|
476
594
|
rescue StandardError => e
|
|
477
595
|
{ error: "#{e.class}: #{e.message}" }
|
|
478
596
|
end
|
|
@@ -496,6 +614,70 @@ module PWN
|
|
|
496
614
|
# privates
|
|
497
615
|
# ----------------------------------------------------------------
|
|
498
616
|
|
|
617
|
+
# P24 — single-shot text critic (no tools, no persona Loop.run).
|
|
618
|
+
private_class_method def self.critic_text_only(opts = {})
|
|
619
|
+
req = opts[:request].to_s
|
|
620
|
+
final = opts[:final].to_s
|
|
621
|
+
return { verdict: :pass, source: :text_only_empty } if final.strip.empty?
|
|
622
|
+
|
|
623
|
+
reply = if reflect_available?
|
|
624
|
+
Reflect.on(
|
|
625
|
+
request: "You are a strict critic. Given REQUEST and ANSWER, reply PASS or FLAW: <one line>.\nREQUEST:\n#{req[0, 600]}\nANSWER:\n#{final[0, 1_200]}",
|
|
626
|
+
suppress_pii_warning: true
|
|
627
|
+
).to_s
|
|
628
|
+
else
|
|
629
|
+
# heuristic fallback: empty / self-reported failure / budget stop
|
|
630
|
+
if final.match?(/\[pwn-ai\].*budget|iteration budget exhausted|i (was )?unable to|failed to\b/i)
|
|
631
|
+
'FLAW: answer reports failure or budget exhaustion'
|
|
632
|
+
else
|
|
633
|
+
'PASS'
|
|
634
|
+
end
|
|
635
|
+
end
|
|
636
|
+
if reply.to_s.strip.upcase.start_with?('PASS')
|
|
637
|
+
{ verdict: :pass, source: :text_only, confidence: 0.55 }
|
|
638
|
+
else
|
|
639
|
+
flaw = reply.to_s.sub(/\AFLAW:\s*/i, '').strip[0, 300]
|
|
640
|
+
# Do NOT Mistakes.record assistant_answer thrash from text_only path —
|
|
641
|
+
# that was feeding the budget-exhaustion mistake pile.
|
|
642
|
+
{ verdict: :flaw, flaw: flaw, source: :text_only, confidence: 0.55 }
|
|
643
|
+
end
|
|
644
|
+
rescue StandardError => e
|
|
645
|
+
{ verdict: :pass, source: :text_only_error, error: e.message }
|
|
646
|
+
end
|
|
647
|
+
|
|
648
|
+
# P9 — turn a critic flaw into a DPO-chosen REVISED answer (trajectory
|
|
649
|
+
# shaped), not "CORRECTION: <flaw>" commentary. Best-effort: ask Reflect
|
|
650
|
+
# to rewrite; fall back to a structured scaffold that still contains the
|
|
651
|
+
# original answer + the concrete fix.
|
|
652
|
+
private_class_method def self.revise_after_flaw(opts = {})
|
|
653
|
+
req = opts[:request].to_s
|
|
654
|
+
final = opts[:final].to_s
|
|
655
|
+
flaw = opts[:flaw].to_s
|
|
656
|
+
revised = nil
|
|
657
|
+
if reflect_available?
|
|
658
|
+
prompt = <<~P
|
|
659
|
+
Revise the ANSWER so it no longer has this flaw. Return the FULL
|
|
660
|
+
corrected answer only (no preamble, no "CORRECTION:" prefix).
|
|
661
|
+
REQUEST: #{req[0, 600]}
|
|
662
|
+
FLAW: #{flaw[0, 300]}
|
|
663
|
+
ANSWER: #{final[0, 1_500]}
|
|
664
|
+
P
|
|
665
|
+
revised = Reflect.on(request: prompt, suppress_pii_warning: true).to_s.strip
|
|
666
|
+
end
|
|
667
|
+
if revised.to_s.strip.empty? || revised.strip == final.strip || revised.match?(/\A\s*CORRECTION:/i)
|
|
668
|
+
revised = <<~REV.strip
|
|
669
|
+
REVISED ANSWER (addresses: #{flaw[0, 180]}):
|
|
670
|
+
#{final[0, 1_200]}
|
|
671
|
+
|
|
672
|
+
Fix applied: #{flaw[0, 300]}
|
|
673
|
+
Do not repeat the flawed claim or step above; prefer verified evidence from tools.
|
|
674
|
+
REV
|
|
675
|
+
end
|
|
676
|
+
revised.to_s[0, 4_000]
|
|
677
|
+
rescue StandardError
|
|
678
|
+
"REVISED ANSWER (addresses: #{opts[:flaw].to_s[0, 180]}):\n#{opts[:final].to_s[0, 1_200]}"
|
|
679
|
+
end
|
|
680
|
+
|
|
499
681
|
private_class_method def self.generate_reproducers(opts = {})
|
|
500
682
|
m = opts[:mistake]
|
|
501
683
|
count = (opts[:count] || 2).to_i
|
|
@@ -581,13 +763,34 @@ module PWN
|
|
|
581
763
|
'Use pwn_eval to list PWN::AI::Agent constants',
|
|
582
764
|
'Return Dir.pwd from pwn_eval'
|
|
583
765
|
]
|
|
584
|
-
|
|
766
|
+
when 'agent_loop', 'assistant_answer'
|
|
767
|
+
# P17 — dominant live failure: iteration / critic budget exhaustion.
|
|
768
|
+
# Practise finishing under a tight tool budget, not shell shapes.
|
|
585
769
|
[
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
770
|
+
'Answer in one shell call: print kernel release with uname -r',
|
|
771
|
+
'In at most two tools, show cwd and ruby version then stop',
|
|
772
|
+
'Give a final answer with no tools: what is 7 times 8?',
|
|
773
|
+
'Finish under three iterations: list /tmp and report file count',
|
|
774
|
+
'Do not explore — one pwn_eval of Dir.pwd and return the path',
|
|
775
|
+
'Short plan then one command: show free disk with df -h /'
|
|
590
776
|
]
|
|
777
|
+
else
|
|
778
|
+
if err.include?('budget exhausted') || err.include?('iteration budget') ||
|
|
779
|
+
(err.include?('handler_error') && err.include?('budget'))
|
|
780
|
+
[
|
|
781
|
+
'Answer in one shell call: print kernel release with uname -r',
|
|
782
|
+
'In at most two tools, show cwd and ruby version then stop',
|
|
783
|
+
'Give a final answer with no tools: what is 7 times 8?',
|
|
784
|
+
'Finish under three iterations: list /tmp and report file count'
|
|
785
|
+
]
|
|
786
|
+
else
|
|
787
|
+
[
|
|
788
|
+
"Demonstrate a correct use of the #{tool} tool on this host",
|
|
789
|
+
"Use #{tool} to answer a simple factual question about this system",
|
|
790
|
+
"Show a minimal successful #{tool} call with valid arguments",
|
|
791
|
+
"Recover from a bad #{tool} invocation without retrying the same args"
|
|
792
|
+
]
|
|
793
|
+
end
|
|
591
794
|
end
|
|
592
795
|
Array.new(n) { |i| pool[i % pool.length] }
|
|
593
796
|
end
|
|
@@ -654,8 +857,29 @@ module PWN
|
|
|
654
857
|
|
|
655
858
|
private_class_method def self.self_play(opts = {})
|
|
656
859
|
sid = PWN::Sessions.create(title: "curriculum #{opts[:tag]}")[:id]
|
|
657
|
-
|
|
658
|
-
|
|
860
|
+
# P23 — short-horizon graded tasks: cap iters so practice actually
|
|
861
|
+
# teaches finish-under-N instead of letting DEFAULT_MAX_ITERS mask it.
|
|
862
|
+
prompt = opts[:prompt].to_s
|
|
863
|
+
short = prompt.match?(/\b(one shell|at most two|no tools|three iterations|finish under|do not explore|short plan)\b/i)
|
|
864
|
+
prev_max = :__unset__
|
|
865
|
+
capped = false
|
|
866
|
+
if short && defined?(PWN::Env) && PWN::Env.is_a?(Hash) && PWN::Env[:ai].is_a?(Hash) && PWN::Env[:ai][:agent].is_a?(Hash) && !PWN::Env[:ai][:agent].frozen?
|
|
867
|
+
prev_max = PWN::Env[:ai][:agent].key?(:max_iters) ? PWN::Env[:ai][:agent][:max_iters] : :__unset__
|
|
868
|
+
PWN::Env[:ai][:agent][:max_iters] = 5
|
|
869
|
+
capped = true
|
|
870
|
+
end
|
|
871
|
+
begin
|
|
872
|
+
final = Loop.run(request: prompt, session_id: sid, enabled_toolsets: %w[terminal pwn memory learning])
|
|
873
|
+
ensure
|
|
874
|
+
if capped && defined?(PWN::Env) && PWN::Env[:ai].is_a?(Hash) && PWN::Env[:ai][:agent].is_a?(Hash) && !PWN::Env[:ai][:agent].frozen?
|
|
875
|
+
if prev_max == :__unset__
|
|
876
|
+
PWN::Env[:ai][:agent].delete(:max_iters)
|
|
877
|
+
else
|
|
878
|
+
PWN::Env[:ai][:agent][:max_iters] = prev_max
|
|
879
|
+
end
|
|
880
|
+
end
|
|
881
|
+
end
|
|
882
|
+
v = Reward.judge(request: prompt, final: final, session_id: sid, commit: false)
|
|
659
883
|
# 2.4 — capture tool trace for structured_fix.winning_trace
|
|
660
884
|
trace = begin
|
|
661
885
|
if defined?(PWN::Sessions)
|
|
@@ -673,21 +897,25 @@ module PWN
|
|
|
673
897
|
end
|
|
674
898
|
|
|
675
899
|
private_class_method def self.score_branch(opts = {})
|
|
676
|
-
|
|
677
|
-
|
|
900
|
+
score_branch_detailed(**opts)[:score]
|
|
901
|
+
end
|
|
902
|
+
|
|
903
|
+
# Returns {score:, mode: :real_dispatch|:imagined|:default, trace:}.
|
|
904
|
+
# Callers that need honest advantage estimation should inspect :mode.
|
|
905
|
+
private_class_method def self.score_branch_detailed(opts = {})
|
|
678
906
|
branch = opts[:branch].to_s
|
|
679
907
|
request = opts[:request].to_s
|
|
680
908
|
real = try_real_dispatch_score(branch: branch)
|
|
681
|
-
return real if real
|
|
909
|
+
return { score: real, mode: :real_dispatch, trace: branch.to_s[0, 500] } if real
|
|
682
910
|
|
|
683
|
-
return 0.5 unless reflect_available?
|
|
911
|
+
return { score: 0.5, mode: :default, trace: nil } unless reflect_available?
|
|
684
912
|
|
|
685
913
|
req = "Goal: #{request}\nProposed next action: #{branch}\nOn a scale 0.0-1.0, how likely is this to advance the goal on a Kali Linux host? Reply with ONLY the number."
|
|
686
914
|
imagined = Reflect.on(request: req, suppress_pii_warning: true).to_s[/[01](?:\.\d+)?/].to_f.clamp(0.0, 1.0)
|
|
687
915
|
# haircut imagined scores so they never outrank a real dispatch
|
|
688
|
-
(imagined * 0.6).clamp(0.0, 0.6)
|
|
916
|
+
{ score: (imagined * 0.6).clamp(0.0, 0.6), mode: :imagined, trace: nil }
|
|
689
917
|
rescue StandardError
|
|
690
|
-
0.5
|
|
918
|
+
{ score: 0.5, mode: :default, trace: nil }
|
|
691
919
|
end
|
|
692
920
|
|
|
693
921
|
# Best-effort: if branch names a registered tool + args, run ONE Dispatch
|
|
@@ -767,6 +995,114 @@ module PWN
|
|
|
767
995
|
{ baseline: opts[:baseline], candidate: opts[:candidate], baseline_resolved: baseline, candidate_resolved: candid, evalset_size: evalset.length }
|
|
768
996
|
end
|
|
769
997
|
|
|
998
|
+
# P11 — promote only when candidate beats baseline on (a) resolved count
|
|
999
|
+
# with margin, (b) mean judge score, and (c) no smoke regression. Smoke
|
|
1000
|
+
# set is fixed natural tasks, not Mistakes.top, so eval-set memorisation
|
|
1001
|
+
# cannot self-promote.
|
|
1002
|
+
private_class_method def self.ab_gate_v2(opts = {})
|
|
1003
|
+
evalset = Array(opts[:evalset])
|
|
1004
|
+
baseline = replay_on_detailed(tag: opts[:baseline], evalset: evalset)
|
|
1005
|
+
candid = replay_on_detailed(tag: opts[:candidate], evalset: evalset)
|
|
1006
|
+
smoke = smoke_eval_set
|
|
1007
|
+
base_smoke = replay_on_detailed(tag: opts[:baseline], evalset: smoke)
|
|
1008
|
+
cand_smoke = replay_on_detailed(tag: opts[:candidate], evalset: smoke)
|
|
1009
|
+
|
|
1010
|
+
b_res = baseline[:resolved].to_i
|
|
1011
|
+
c_res = candid[:resolved].to_i
|
|
1012
|
+
n = [evalset.length, 1].max
|
|
1013
|
+
delta = c_res - b_res
|
|
1014
|
+
rel = delta.to_f / n
|
|
1015
|
+
resolved_win = delta >= 1 && (n < 10 || rel >= 0.05)
|
|
1016
|
+
|
|
1017
|
+
b_mean = baseline[:mean_score].to_f
|
|
1018
|
+
c_mean = candid[:mean_score].to_f
|
|
1019
|
+
mean_win = c_mean + 1e-9 >= b_mean
|
|
1020
|
+
|
|
1021
|
+
smoke_ok = cand_smoke[:resolved].to_i >= base_smoke[:resolved].to_i &&
|
|
1022
|
+
cand_smoke[:mean_score].to_f + 0.05 >= base_smoke[:mean_score].to_f
|
|
1023
|
+
|
|
1024
|
+
promote = resolved_win && mean_win && smoke_ok
|
|
1025
|
+
{
|
|
1026
|
+
baseline: opts[:baseline],
|
|
1027
|
+
candidate: opts[:candidate],
|
|
1028
|
+
baseline_resolved: b_res,
|
|
1029
|
+
candidate_resolved: c_res,
|
|
1030
|
+
baseline_mean: b_mean.round(3),
|
|
1031
|
+
candidate_mean: c_mean.round(3),
|
|
1032
|
+
delta_resolved: delta,
|
|
1033
|
+
relative_delta: rel.round(3),
|
|
1034
|
+
smoke: {
|
|
1035
|
+
baseline_resolved: base_smoke[:resolved],
|
|
1036
|
+
candidate_resolved: cand_smoke[:resolved],
|
|
1037
|
+
baseline_mean: base_smoke[:mean_score],
|
|
1038
|
+
candidate_mean: cand_smoke[:mean_score]
|
|
1039
|
+
},
|
|
1040
|
+
resolved_win: resolved_win,
|
|
1041
|
+
mean_win: mean_win,
|
|
1042
|
+
smoke_ok: smoke_ok,
|
|
1043
|
+
promote: promote,
|
|
1044
|
+
evalset_size: evalset.length,
|
|
1045
|
+
gate_version: 2
|
|
1046
|
+
}
|
|
1047
|
+
end
|
|
1048
|
+
|
|
1049
|
+
private_class_method def self.smoke_eval_set
|
|
1050
|
+
[
|
|
1051
|
+
{ signature: 'smoke_uname', prompt: 'Print the kernel release with uname -r' },
|
|
1052
|
+
{ signature: 'smoke_pwd', prompt: 'Show the current working directory' },
|
|
1053
|
+
{ signature: 'smoke_ruby', prompt: 'Display the active ruby version' }
|
|
1054
|
+
]
|
|
1055
|
+
end
|
|
1056
|
+
|
|
1057
|
+
# P19 — weight promote requires scrubbed trajectory diversity, not just
|
|
1058
|
+
# A/B eval win. export_ready remains the correct posture otherwise.
|
|
1059
|
+
private_class_method def self.preference_diet_gate(opts = {})
|
|
1060
|
+
return { ok: false, reason: 'no Reward' } unless defined?(Reward)
|
|
1061
|
+
|
|
1062
|
+
bal = if Reward.respond_to?(:preference_balance)
|
|
1063
|
+
Reward.preference_balance(limit: opts[:limit] || 10_000, scrub: true)
|
|
1064
|
+
else
|
|
1065
|
+
preference_balance(limit: opts[:limit] || 10_000)
|
|
1066
|
+
end
|
|
1067
|
+
total = bal[:kept].to_i
|
|
1068
|
+
total = bal[:total].to_i if total <= 0
|
|
1069
|
+
return { ok: false, reason: 'too_few_pairs', total: total, min: 12 } if total < 12
|
|
1070
|
+
|
|
1071
|
+
frac = bal[:fractions] || {}
|
|
1072
|
+
max_share = frac.values.map(&:to_f).max || 1.0
|
|
1073
|
+
traj_frac = bal[:trajectory_fraction].to_f
|
|
1074
|
+
ok = !bal[:monoculture] && max_share <= 0.45 && traj_frac >= 0.30
|
|
1075
|
+
{
|
|
1076
|
+
ok: ok,
|
|
1077
|
+
total: total,
|
|
1078
|
+
monoculture: bal[:monoculture],
|
|
1079
|
+
max_source_share: max_share.round(3),
|
|
1080
|
+
trajectory_fraction: traj_frac.round(3),
|
|
1081
|
+
by_source: bal[:by_source],
|
|
1082
|
+
reason: ok ? 'diet_ok' : 'need_trajectory_diversity_or_rebalance'
|
|
1083
|
+
}
|
|
1084
|
+
rescue StandardError => e
|
|
1085
|
+
{ ok: false, reason: "#{e.class}: #{e.message}" }
|
|
1086
|
+
end
|
|
1087
|
+
|
|
1088
|
+
private_class_method def self.replay_on_detailed(opts = {})
|
|
1089
|
+
tag = opts[:tag].to_s
|
|
1090
|
+
return { resolved: 0, mean_score: 0.0, scores: [] } if tag.empty?
|
|
1091
|
+
|
|
1092
|
+
scores = []
|
|
1093
|
+
with_ollama_model(tag: tag) do
|
|
1094
|
+
Array(opts[:evalset]).each do |e|
|
|
1095
|
+
r = self_play(prompt: e[:prompt], tag: "gate:#{tag}")
|
|
1096
|
+
scores << r[:score].to_f
|
|
1097
|
+
end
|
|
1098
|
+
end
|
|
1099
|
+
resolved = scores.count { |s| s >= 0.7 }
|
|
1100
|
+
mean = scores.empty? ? 0.0 : (scores.sum / scores.length)
|
|
1101
|
+
{ resolved: resolved, mean_score: mean.round(3), scores: scores }
|
|
1102
|
+
rescue StandardError
|
|
1103
|
+
{ resolved: 0, mean_score: 0.0, scores: [] }
|
|
1104
|
+
end
|
|
1105
|
+
|
|
770
1106
|
private_class_method def self.replay_on(opts = {})
|
|
771
1107
|
tag = opts[:tag].to_s
|
|
772
1108
|
return 0 if tag.empty?
|
|
@@ -1118,7 +1454,7 @@ module PWN
|
|
|
1118
1454
|
puts <<~USAGE
|
|
1119
1455
|
USAGE:
|
|
1120
1456
|
# Tier 4 — self-play
|
|
1121
|
-
PWN::AI::Agent::Curriculum.practice(limit: 3) # S1
|
|
1457
|
+
PWN::AI::Agent::Curriculum.practice(limit: 3) # S1 + P14 trajectory DPO pairs + P17 budget-first
|
|
1122
1458
|
PWN::AI::Agent::Curriculum.offline_judge(since_hours: 24) # P3 offline ORM/PRM fill
|
|
1123
1459
|
PWN::AI::Agent::Curriculum.preference_balance # P5 W1 diversity report
|
|
1124
1460
|
PWN::AI::Agent::Curriculum.counterfactual(request:, name:, args:, error:, hint:) # S2 A/B → DPO pair
|
|
@@ -1127,7 +1463,7 @@ module PWN
|
|
|
1127
1463
|
PWN::AI::Agent::Curriculum.hindsight(request:, final:, session_id:) # C3 HER soft-relabel
|
|
1128
1464
|
|
|
1129
1465
|
# Tier 5 — close the weight loop
|
|
1130
|
-
PWN::AI::Agent::Curriculum.train_and_gate(dry_run: true) # W2 export-ready; promote only with trainer+dry_run:false
|
|
1466
|
+
PWN::AI::Agent::Curriculum.train_and_gate(dry_run: true) # W2 export-ready; P11 gate v2 promote only with trainer+dry_run:false
|
|
1131
1467
|
PWN::AI::Agent::Curriculum.calibrate(predicted: 0.8, actual: 1.0) # W3 Brier → Metrics[:calibration]
|
|
1132
1468
|
|
|
1133
1469
|
Cron self-improvement (seeded by PWN::Cron.install_defaults):
|