pwn 0.5.721 → 0.5.723
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/Gemfile +2 -2
- data/bin/pwn_setup +5 -5
- data/documentation/AI-Integration.md +34 -1
- data/documentation/Policy-Benchmark.md +151 -0
- data/documentation/Reinforcement-Learning.md +27 -1
- data/etc/default_skills/pwn/ai/agent/engagement/SKILL.md +1 -0
- data/etc/default_skills/pwn/ai/agent/learning/SKILL.md +2 -0
- data/etc/default_skills/pwn/ai/agent/metrics/SKILL.md +4 -0
- data/etc/default_skills/pwn/ai/agent/mistakes/SKILL.md +2 -0
- data/etc/default_skills/pwn/ai/agent/policy/SKILL.md +1 -0
- data/etc/default_skills/pwn/ai/agent/reward/SKILL.md +2 -0
- data/etc/default_skills/pwn/ai/agent/swarm/SKILL.md +6 -0
- data/etc/default_skills/pwn/ai/agent/tools/capabilities/SKILL.md +45 -0
- data/etc/default_skills/pwn/ai/agent/tools/context/SKILL.md +45 -0
- data/etc/default_skills/pwn/ai/context/SKILL.md +50 -0
- data/etc/default_skills/pwn/ai/http_retry/SKILL.md +7 -0
- data/etc/default_skills/pwn/ai/http_retry/references/urls.md +4 -0
- data/etc/default_skills/pwn/ai/open_ai/SKILL.md +1 -0
- data/etc/default_skills/pwn/ai/open_ai/references/urls.md +1 -0
- data/etc/default_skills/pwn/plugins/exploit_dev/SKILL.md +1 -0
- data/etc/default_skills/pwn/plugins/findings/SKILL.md +1 -0
- data/etc/default_skills/pwn/plugins/gdb/SKILL.md +1 -0
- data/etc/default_skills/pwn/plugins/gdbmi/SKILL.md +55 -0
- data/etc/default_skills/pwn/plugins/ghidra_headless/SKILL.md +49 -0
- data/etc/default_skills/pwn/plugins/jobs/SKILL.md +6 -0
- data/etc/default_skills/pwn/plugins/packet/SKILL.md +3 -0
- data/etc/default_skills/pwn/plugins/preflight_checker/SKILL.md +1 -0
- data/etc/default_skills/pwn/plugins/radare2/SKILL.md +1 -0
- data/etc/default_skills/pwn/plugins/transparent_browser/SKILL.md +3 -0
- data/etc/default_skills/pwn/reports/engagement/SKILL.md +3 -2
- data/lib/pwn/ai/agent/curriculum.rb +37 -45
- data/lib/pwn/ai/agent/dispatch.rb +9 -0
- data/lib/pwn/ai/agent/engagement.rb +59 -0
- data/lib/pwn/ai/agent/learning.rb +111 -44
- data/lib/pwn/ai/agent/loop.rb +50 -8
- data/lib/pwn/ai/agent/metrics.rb +70 -2
- data/lib/pwn/ai/agent/mistakes.rb +67 -1
- data/lib/pwn/ai/agent/policy.rb +142 -16
- data/lib/pwn/ai/agent/registry.rb +1 -1
- data/lib/pwn/ai/agent/reward.rb +153 -53
- data/lib/pwn/ai/agent/swarm.rb +235 -35
- data/lib/pwn/ai/agent/tool_guard.rb +13 -1
- data/lib/pwn/ai/agent/tools/artifacts.rb +50 -3
- data/lib/pwn/ai/agent/tools/capabilities.rb +19 -0
- data/lib/pwn/ai/agent/tools/context.rb +38 -0
- data/lib/pwn/ai/agent/tools/finding_record.rb +18 -0
- data/lib/pwn/ai/agent/tools/fuzz_campaign.rb +10 -1
- data/lib/pwn/ai/agent/tools/job_run.rb +32 -0
- data/lib/pwn/ai/agent/tools/learning.rb +5 -6
- data/lib/pwn/ai/agent/tools/metrics.rb +16 -0
- data/lib/pwn/ai/agent/tools/pty_session.rb +4 -4
- data/lib/pwn/ai/agent/tools/ruby_eval.rb +6 -5
- data/lib/pwn/ai/agent/tools/shell.rb +10 -1
- data/lib/pwn/ai/agent/tools/skills.rb +30 -0
- data/lib/pwn/ai/agent/tools/swarm.rb +8 -2
- data/lib/pwn/ai/context.rb +193 -0
- data/lib/pwn/ai/http_retry.rb +53 -7
- data/lib/pwn/ai/open_ai.rb +302 -45
- data/lib/pwn/ai.rb +1 -0
- data/lib/pwn/migrate.rb +10 -1
- data/lib/pwn/plugins/artifact_registry.rb +40 -17
- data/lib/pwn/plugins/binary_parser.rb +4 -1
- data/lib/pwn/plugins/exploit_dev.rb +15 -0
- data/lib/pwn/plugins/findings.rb +48 -8
- data/lib/pwn/plugins/gdb.rb +17 -0
- data/lib/pwn/plugins/gdbmi.rb +128 -0
- data/lib/pwn/plugins/ghidra_headless.rb +104 -0
- data/lib/pwn/plugins/jobs.rb +72 -0
- data/lib/pwn/plugins/packet.rb +51 -0
- data/lib/pwn/plugins/preflight_checker.rb +29 -0
- data/lib/pwn/plugins/process_tube.rb +24 -7
- data/lib/pwn/plugins/radare2.rb +14 -2
- data/lib/pwn/plugins/repl.rb +6 -1
- data/lib/pwn/plugins/transparent_browser.rb +64 -0
- data/lib/pwn/plugins.rb +2 -0
- data/lib/pwn/reports/engagement.rb +19 -0
- data/lib/pwn/sessions.rb +3 -1
- data/lib/pwn/version.rb +1 -1
- data/scripts/benchmark_policy.rb +276 -0
- data/spec/documentation/installation_md_spec.rb +18 -4
- data/spec/integration/reinforced_feedback_loop_spec.rb +20 -13
- data/spec/lib/pwn/ai/agent/curriculum_spec.rb +267 -0
- data/spec/lib/pwn/ai/agent/engagement_spec.rb +12 -0
- data/spec/lib/pwn/ai/agent/learning_spec.rb +81 -3
- data/spec/lib/pwn/ai/agent/loop_spec.rb +51 -0
- data/spec/lib/pwn/ai/agent/metrics_spec.rb +44 -0
- data/spec/lib/pwn/ai/agent/mistakes_spec.rb +10 -0
- data/spec/lib/pwn/ai/agent/policy_spec.rb +103 -0
- data/spec/lib/pwn/ai/agent/registry_spec.rb +62 -0
- data/spec/lib/pwn/ai/agent/reward_spec.rb +175 -12
- data/spec/lib/pwn/ai/agent/swarm_spec.rb +121 -1
- data/spec/lib/pwn/ai/agent/tool_guard_spec.rb +6 -0
- data/spec/lib/pwn/ai/agent/tools/capabilities_spec.rb +14 -0
- data/spec/lib/pwn/ai/agent/tools/context_spec.rb +14 -0
- data/spec/lib/pwn/ai/agent/tools/job_run_spec.rb +2 -0
- data/spec/lib/pwn/ai/agent/tools/learning_spec.rb +25 -0
- data/spec/lib/pwn/ai/context_spec.rb +48 -0
- data/spec/lib/pwn/ai/http_retry_spec.rb +27 -0
- data/spec/lib/pwn/ai/open_ai_oauth_transport_spec.rb +245 -0
- data/spec/lib/pwn/ai/open_ai_spec.rb +150 -0
- data/spec/lib/pwn/migrate_spec.rb +24 -0
- data/spec/lib/pwn/plugins/artifact_registry_spec.rb +19 -0
- data/spec/lib/pwn/plugins/findings_spec.rb +2 -0
- data/spec/lib/pwn/plugins/gdbmi_spec.rb +17 -0
- data/spec/lib/pwn/plugins/ghidra_headless_spec.rb +17 -0
- data/third_party/pwn_rdoc.jsonl +86 -2
- metadata +24 -5
data/lib/pwn/ai/agent/policy.rb
CHANGED
|
@@ -44,11 +44,20 @@ module PWN
|
|
|
44
44
|
MAX_TRAJ = 2_000
|
|
45
45
|
GOLD_MIN = 0.6
|
|
46
46
|
VISITS_MIN = 2
|
|
47
|
+
CONTEXT_VISITS_MIN = 3
|
|
47
48
|
COLD_EPISODES = 8
|
|
48
49
|
WARM_EPISODES = 40
|
|
49
50
|
TASK_MOD = 16
|
|
50
51
|
ACTION_MOD = 16
|
|
51
52
|
EP_KEY = :pwn_policy_episode
|
|
53
|
+
OPERATIONS = %w[read list search inspect write create update delete execute start stop status poll].freeze
|
|
54
|
+
RESULT_TYPES = %w[success failure timeout enoent eacces auth_required network syntax exit127 exit126 nonzero_exit handler_error invalid_payload].freeze
|
|
55
|
+
ARGUMENT_ROLES = {
|
|
56
|
+
'action' => 'operation', 'operation' => 'operation', 'op' => 'operation',
|
|
57
|
+
'path' => 'path', 'file' => 'path', 'directory' => 'path',
|
|
58
|
+
'command' => 'program', 'code' => 'program', 'query' => 'query',
|
|
59
|
+
'url' => 'url', 'timeout' => 'control', 'limit' => 'control'
|
|
60
|
+
}.freeze
|
|
52
61
|
|
|
53
62
|
# ----------------------------------------------------------------
|
|
54
63
|
# Feature → discrete state
|
|
@@ -110,7 +119,7 @@ module PWN
|
|
|
110
119
|
tab[:q].each_value { |acts| pairs += acts.length if acts.is_a?(Hash) }
|
|
111
120
|
return true if warmed && pairs >= COLD_EPISODES
|
|
112
121
|
|
|
113
|
-
traj_n = trajectories(limit: COLD_EPISODES).
|
|
122
|
+
traj_n = trajectories(limit: COLD_EPISODES).count { |ep| !ep[:score].nil? }
|
|
114
123
|
warmed && traj_n >= COLD_EPISODES
|
|
115
124
|
rescue StandardError
|
|
116
125
|
false
|
|
@@ -145,7 +154,7 @@ module PWN
|
|
|
145
154
|
)
|
|
146
155
|
ep = {
|
|
147
156
|
session_id: sid,
|
|
148
|
-
request: opts[:request].to_s
|
|
157
|
+
request: opts[:request].to_s.dup,
|
|
149
158
|
kind: normalize_kind(raw: opts[:kind]),
|
|
150
159
|
intent: opts[:intent].to_s,
|
|
151
160
|
engine: opts[:engine].to_s,
|
|
@@ -168,6 +177,9 @@ module PWN
|
|
|
168
177
|
# step = PWN::AI::Agent::Policy.observe_step(
|
|
169
178
|
# session_id: 'optional - must match begin_episode when set',
|
|
170
179
|
# action: 'required - tool name',
|
|
180
|
+
# args: 'optional - arguments, reduced to fixed roles/types, never stored raw',
|
|
181
|
+
# operation: 'optional - known operation override; otherwise derived from args',
|
|
182
|
+
# result_type: 'optional - Reward.semantic_ok shape; fixed allowlist only',
|
|
171
183
|
# ok: 'required - Boolean, Reward.semantic_ok',
|
|
172
184
|
# duration: 'optional - Float seconds',
|
|
173
185
|
# ts_state: 'optional - TaskSummarizer state',
|
|
@@ -211,9 +223,14 @@ module PWN
|
|
|
211
223
|
engine: ep[:engine],
|
|
212
224
|
ts_state: opts[:ts_state]
|
|
213
225
|
)
|
|
226
|
+
context = action_context(opts)
|
|
227
|
+
context_s2 = contextual_state(state: s2, action: action, context: context)
|
|
214
228
|
trans = {
|
|
215
229
|
state: s,
|
|
216
230
|
action: action,
|
|
231
|
+
action_context: context,
|
|
232
|
+
context_state: ep[:context_state],
|
|
233
|
+
next_context_state: context_s2,
|
|
217
234
|
reward: reward,
|
|
218
235
|
next_state: s2,
|
|
219
236
|
ok: ok,
|
|
@@ -222,6 +239,7 @@ module PWN
|
|
|
222
239
|
}
|
|
223
240
|
ep[:steps] << trans
|
|
224
241
|
ep[:state] = s2
|
|
242
|
+
ep[:context_state] = context_s2
|
|
225
243
|
ep[:last_action] = action
|
|
226
244
|
trans
|
|
227
245
|
rescue StandardError => e
|
|
@@ -278,15 +296,19 @@ module PWN
|
|
|
278
296
|
ep[:steps].last[:terminal] = true
|
|
279
297
|
ep[:score] = opts[:score]
|
|
280
298
|
ep[:verdict] = opts[:verdict]
|
|
281
|
-
ep[:return] = discounted_return(steps: ep[:steps])
|
|
299
|
+
ep[:return] = opts[:score].nil? ? nil : discounted_return(steps: ep[:steps])
|
|
282
300
|
ep[:ended_at] = Time.now.utc.iso8601
|
|
283
301
|
|
|
284
302
|
n_td = 0
|
|
285
303
|
n_pg = 0
|
|
286
304
|
ep[:steps].each_with_index do |tr, idx|
|
|
287
|
-
|
|
305
|
+
next if opts[:score].nil?
|
|
306
|
+
|
|
288
307
|
g = discounted_return(steps: ep[:steps][idx..])
|
|
289
|
-
|
|
308
|
+
transition_variants(transition: tr).each do |variant|
|
|
309
|
+
n_td += 1 if update_q!(transition: variant)
|
|
310
|
+
n_pg += 1 if update_pg!(state: variant[:state], action: variant[:action], advantage: g - value(state: variant[:state]))
|
|
311
|
+
end
|
|
290
312
|
end
|
|
291
313
|
|
|
292
314
|
persist_episode!(episode: ep)
|
|
@@ -401,11 +423,16 @@ module PWN
|
|
|
401
423
|
|
|
402
424
|
tab = load
|
|
403
425
|
visits = read_visit(table: tab, state: s, action: a)
|
|
404
|
-
|
|
426
|
+
context = opts[:context_state]
|
|
427
|
+
context = nil unless context.to_s.start_with?("#{s}~ctx:")
|
|
428
|
+
contextual_visits = read_visit(table: tab, state: context, action: a)
|
|
429
|
+
qsa = routing_q(table: tab, state: s, context_state: context, action: a)
|
|
405
430
|
# Tiny visit counts stay at 0 unless the value is already decisive.
|
|
406
|
-
return 0.0 if visits < VISITS_MIN && qsa.abs < 0.08
|
|
431
|
+
return 0.0 if contextual_visits < CONTEXT_VISITS_MIN && visits < VISITS_MIN && qsa.abs < 0.08
|
|
407
432
|
|
|
408
|
-
|
|
433
|
+
actions = ((tab[:q][s.to_s.to_sym] || {}).keys + (tab[:q][context.to_s.to_sym] || {}).keys).uniq
|
|
434
|
+
baseline = actions.map { |act| routing_q(table: tab, state: s, context_state: context, action: act) }.max || 0.0
|
|
435
|
+
(qsa - baseline).clamp(-1.0, 1.0).round(4)
|
|
409
436
|
rescue StandardError
|
|
410
437
|
0.0
|
|
411
438
|
end
|
|
@@ -432,7 +459,8 @@ module PWN
|
|
|
432
459
|
return { action: actions.sample, reason: :explore, state: s, epsilon: eps } if rand < eps
|
|
433
460
|
|
|
434
461
|
tab = load
|
|
435
|
-
|
|
462
|
+
context = opts.key?(:context_state) ? opts[:context_state] : (current_context_state if s == current_state)
|
|
463
|
+
scored = actions.map { |a| [a, routing_q(table: tab, state: s, context_state: context, action: a)] }
|
|
436
464
|
best = scored.max_by { |_, v| v }
|
|
437
465
|
{ action: best[0], q: best[1].round(4), reason: :greedy, state: s, ranked: scored.sort_by { |_, v| -v } }
|
|
438
466
|
rescue StandardError => e
|
|
@@ -448,6 +476,13 @@ module PWN
|
|
|
448
476
|
Thread.current[EP_KEY]
|
|
449
477
|
end
|
|
450
478
|
|
|
479
|
+
# Previous action features are available BEFORE the next tool choice;
|
|
480
|
+
# the candidate's own result must never leak into its decision state.
|
|
481
|
+
public_class_method def self.current_context_state
|
|
482
|
+
ep = current_episode
|
|
483
|
+
ep.is_a?(Hash) ? ep[:context_state] : nil
|
|
484
|
+
end
|
|
485
|
+
|
|
451
486
|
# Hermes split: snapshot + clear the live episode so Loop.maybe_finish_policy
|
|
452
487
|
# is a no-op on the user-visible path while TurnFinalizer re-attaches it
|
|
453
488
|
# on the background review thread.
|
|
@@ -538,7 +573,7 @@ module PWN
|
|
|
538
573
|
# Does not write. Used by task 7 (evaluate policy quality).
|
|
539
574
|
|
|
540
575
|
public_class_method def self.evaluate(opts = {})
|
|
541
|
-
rows = trajectories(limit: opts[:limit] || 200)
|
|
576
|
+
rows = trajectories(limit: opts[:limit] || 200).reject { |ep| ep[:score].nil? }
|
|
542
577
|
return { n: 0, mean_return: nil, greedy_match: nil, mean_abs_td: nil } if rows.empty?
|
|
543
578
|
|
|
544
579
|
tab = load
|
|
@@ -705,6 +740,9 @@ module PWN
|
|
|
705
740
|
#{self}.observe_step(
|
|
706
741
|
session_id: 'optional - must match begin_episode when set',
|
|
707
742
|
action: 'required - tool name',
|
|
743
|
+
args: 'optional - arguments reduced to fixed roles/types only',
|
|
744
|
+
operation: 'optional - allowlisted operation override',
|
|
745
|
+
result_type: 'optional - allowlisted semantic result shape',
|
|
708
746
|
ok: 'required - Boolean, Reward.semantic_ok',
|
|
709
747
|
duration: 'optional - Float seconds',
|
|
710
748
|
ts_state: 'optional - TaskSummarizer state',
|
|
@@ -750,13 +788,15 @@ module PWN
|
|
|
750
788
|
# Q(s,a) − V(s). Unknown / cold-start pairs return 0 so rank is unchanged
|
|
751
789
|
#{self}.advantage(
|
|
752
790
|
state: 'optional - state value consumed by #advantage (defaults to current_state)',
|
|
753
|
-
action: 'optional - action value consumed by #advantage'
|
|
791
|
+
action: 'optional - action value consumed by #advantage',
|
|
792
|
+
context_state: 'optional - previous action context supplied by Registry'
|
|
754
793
|
)
|
|
755
794
|
|
|
756
795
|
# Run recommend and return its result
|
|
757
796
|
#{self}.recommend(
|
|
758
797
|
state: 'optional - default current episode state',
|
|
759
798
|
actions: 'required - Array of tool names',
|
|
799
|
+
context_state: 'optional - previous action context (default live context)',
|
|
760
800
|
epsilon: 'optional - explore probability (default EPSILON)'
|
|
761
801
|
)
|
|
762
802
|
|
|
@@ -766,6 +806,9 @@ module PWN
|
|
|
766
806
|
# Run current episode and return its result
|
|
767
807
|
#{self}.current_episode
|
|
768
808
|
|
|
809
|
+
# Sanitized previous-action state used for advisory routing
|
|
810
|
+
#{self}.current_context_state
|
|
811
|
+
|
|
769
812
|
# Hermes split: snapshot + clear the live episode so Loop.maybe_finish_policy
|
|
770
813
|
#{self}.detach_episode!
|
|
771
814
|
|
|
@@ -832,6 +875,74 @@ module PWN
|
|
|
832
875
|
{ q: {}, h: {}, visits: {}, returns: [], n_updates: 0, td_abs_sum: 0.0, updated_at: nil }
|
|
833
876
|
end
|
|
834
877
|
|
|
878
|
+
private_class_method def self.contextual_state(opts = {})
|
|
879
|
+
"#{opts[:state]}~ctx:a#{action_bucket(name: opts[:action])}:#{JSON.generate(opts[:context])}"
|
|
880
|
+
end
|
|
881
|
+
|
|
882
|
+
private_class_method def self.transition_variants(opts = {})
|
|
883
|
+
tr = opts[:transition]
|
|
884
|
+
return [tr] if tr[:context_state].to_s.empty?
|
|
885
|
+
|
|
886
|
+
[tr, tr.merge(state: tr[:context_state], next_state: tr[:next_context_state])]
|
|
887
|
+
end
|
|
888
|
+
|
|
889
|
+
# Shrink sparse contextual values toward the existing broad Q table.
|
|
890
|
+
# A strict sample floor prevents a single unusual call moving rank.
|
|
891
|
+
private_class_method def self.routing_q(opts = {})
|
|
892
|
+
broad = smoothed_q(table: opts[:table], state: opts[:state], action: opts[:action])
|
|
893
|
+
context = opts[:context_state]
|
|
894
|
+
return broad unless context.to_s.start_with?("#{opts[:state]}~ctx:")
|
|
895
|
+
|
|
896
|
+
n = read_visit(table: opts[:table], state: context, action: opts[:action])
|
|
897
|
+
return broad if n < CONTEXT_VISITS_MIN
|
|
898
|
+
|
|
899
|
+
specific = read_q(table: opts[:table], state: context, action: opts[:action])
|
|
900
|
+
weight = n.to_f / (n + CONTEXT_VISITS_MIN)
|
|
901
|
+
((weight * specific) + ((1.0 - weight) * broad)).round(5)
|
|
902
|
+
end
|
|
903
|
+
|
|
904
|
+
# No arbitrary keys, values, lengths, hashes, paths, or class names.
|
|
905
|
+
# Nested containers are represented by type only, not traversed.
|
|
906
|
+
private_class_method def self.action_context(opts = {})
|
|
907
|
+
args = opts[:args]
|
|
908
|
+
operation = opts[:operation]
|
|
909
|
+
operation = args[:operation] || args['operation'] || args[:action] || args['action'] || args[:op] || args['op'] if operation.nil? && args.is_a?(Hash)
|
|
910
|
+
operation ||= 'execute' if %w[shell pwn_eval].include?(opts[:action].to_s)
|
|
911
|
+
operation = operation.to_s.downcase
|
|
912
|
+
operation = 'other' unless OPERATIONS.include?(operation)
|
|
913
|
+
arguments = { shape: feature_type(value: args) }
|
|
914
|
+
if args.is_a?(Hash)
|
|
915
|
+
size = if args.empty?
|
|
916
|
+
'empty'
|
|
917
|
+
elsif args.length <= 4
|
|
918
|
+
'few'
|
|
919
|
+
else
|
|
920
|
+
'many'
|
|
921
|
+
end
|
|
922
|
+
features = args.map do |key, value|
|
|
923
|
+
role = ARGUMENT_ROLES.fetch(key.to_s, 'other')
|
|
924
|
+
"#{role}:#{feature_type(value: value)}"
|
|
925
|
+
end.uniq.sort
|
|
926
|
+
arguments.merge!(size: size, features: features)
|
|
927
|
+
end
|
|
928
|
+
result = opts[:result_type].to_s
|
|
929
|
+
result = opts[:ok] ? 'success' : 'failure' if result.empty?
|
|
930
|
+
result = 'other' unless RESULT_TYPES.include?(result)
|
|
931
|
+
{ operation: operation, arguments: arguments, result_type: result }
|
|
932
|
+
end
|
|
933
|
+
|
|
934
|
+
private_class_method def self.feature_type(opts = {})
|
|
935
|
+
case opts[:value]
|
|
936
|
+
when Hash then 'object'
|
|
937
|
+
when Array then 'array'
|
|
938
|
+
when String, Symbol then 'string'
|
|
939
|
+
when Numeric then 'number'
|
|
940
|
+
when true, false then 'boolean'
|
|
941
|
+
when nil then 'null'
|
|
942
|
+
else 'other'
|
|
943
|
+
end
|
|
944
|
+
end
|
|
945
|
+
|
|
835
946
|
private_class_method def self.normalize_kind(opts = {})
|
|
836
947
|
raw = opts[:raw].to_s.downcase
|
|
837
948
|
return 'goal' if raw.empty?
|
|
@@ -1073,6 +1184,7 @@ module PWN
|
|
|
1073
1184
|
vals = []
|
|
1074
1185
|
tab[:q].each do |key, acts|
|
|
1075
1186
|
next unless acts.is_a?(Hash) && acts.key?(act)
|
|
1187
|
+
next if key.to_s.include?('~ctx:')
|
|
1076
1188
|
|
|
1077
1189
|
ks = key.to_s.split('|')
|
|
1078
1190
|
next unless ks[0] == kind && ks[1] == task && ks[-1] == eng
|
|
@@ -1092,11 +1204,19 @@ module PWN
|
|
|
1092
1204
|
return { skipped: :no_traj } unless File.exist?(TRAJECTORY_FILE)
|
|
1093
1205
|
|
|
1094
1206
|
tab = load
|
|
1095
|
-
rows = trajectories(limit: opts[:limit] || 400)
|
|
1207
|
+
rows = trajectories(limit: opts[:limit] || 400).reject { |ep| ep[:score].nil? }
|
|
1208
|
+
context_counts = Hash.new(0)
|
|
1209
|
+
rows.each do |ep|
|
|
1210
|
+
Array(ep[:steps]).each do |tr|
|
|
1211
|
+
next if tr[:context_state].to_s.empty? || tr[:action].to_s.empty?
|
|
1212
|
+
|
|
1213
|
+
context_counts[[tr[:context_state].to_s.to_sym, tr[:action].to_s.to_sym]] += 1
|
|
1214
|
+
end
|
|
1215
|
+
end
|
|
1096
1216
|
n = 0
|
|
1097
1217
|
2.times do
|
|
1098
1218
|
rows.reverse_each do |ep|
|
|
1099
|
-
Array(ep[:steps]).each do |tr|
|
|
1219
|
+
Array(ep[:steps]).flat_map { |tr| transition_variants(transition: tr) }.each do |tr|
|
|
1100
1220
|
s = tr[:state].to_s
|
|
1101
1221
|
a = tr[:action].to_s
|
|
1102
1222
|
next if s.empty? || a.empty?
|
|
@@ -1109,13 +1229,19 @@ module PWN
|
|
|
1109
1229
|
target = r + (GAMMA * max_n)
|
|
1110
1230
|
td = target - qsa
|
|
1111
1231
|
write_q!(table: tab, state: s, action: a, value: qsa + (ALPHA * td))
|
|
1112
|
-
bump_visit!(table: tab, state: s, action: a)
|
|
1232
|
+
bump_visit!(table: tab, state: s, action: a) unless s.include?('~ctx:')
|
|
1113
1233
|
tab[:n_updates] = tab[:n_updates].to_i + 1
|
|
1114
1234
|
tab[:td_abs_sum] = tab[:td_abs_sum].to_f + td.abs
|
|
1115
1235
|
n += 1
|
|
1116
1236
|
end
|
|
1117
1237
|
end
|
|
1118
1238
|
end
|
|
1239
|
+
# Replay is not new evidence: restore observed counts, never count
|
|
1240
|
+
# the two passes (or repeated warmups) as independent samples.
|
|
1241
|
+
context_counts.each do |(s, a), count|
|
|
1242
|
+
tab[:visits][s] ||= {}
|
|
1243
|
+
tab[:visits][s][a] = [tab[:visits][s][a].to_i, count].max
|
|
1244
|
+
end
|
|
1119
1245
|
# Credit stored returns toward the episode budget so greedy
|
|
1120
1246
|
# suggestions are not omitted after a successful replay of a
|
|
1121
1247
|
# table that never finished COLD_EPISODES live turns.
|
|
@@ -1151,12 +1277,12 @@ module PWN
|
|
|
1151
1277
|
return unless ep.is_a?(Hash)
|
|
1152
1278
|
|
|
1153
1279
|
tab = load
|
|
1154
|
-
tab[:returns] = (Array(tab[:returns]) + [ep[:return].to_f]).last(200)
|
|
1280
|
+
tab[:returns] = (Array(tab[:returns]) + [ep[:return].to_f]).last(200) unless ep[:score].nil?
|
|
1155
1281
|
save(table: tab)
|
|
1156
1282
|
FileUtils.mkdir_p(File.dirname(TRAJECTORY_FILE))
|
|
1157
1283
|
row = {
|
|
1158
1284
|
session_id: ep[:session_id],
|
|
1159
|
-
|
|
1285
|
+
request_family: task_family(text: ep[:request]),
|
|
1160
1286
|
kind: ep[:kind],
|
|
1161
1287
|
engine: ep[:engine],
|
|
1162
1288
|
started_at: ep[:started_at],
|
|
@@ -276,7 +276,7 @@ module PWN
|
|
|
276
276
|
ucb = defined?(Metrics) && Metrics.respond_to?(:ucb) ? Metrics.ucb(name: e.name) : 0.5
|
|
277
277
|
prm = defined?(Metrics) && Metrics.respond_to?(:prm_advantage) ? Metrics.prm_advantage(name: e.name) : 0.0
|
|
278
278
|
qadv = if eps_q.positive? && pol_state
|
|
279
|
-
Policy.advantage(state: pol_state, action: e.name)
|
|
279
|
+
Policy.advantage(state: pol_state, action: e.name, context_state: Policy.current_context_state)
|
|
280
280
|
else
|
|
281
281
|
0.0
|
|
282
282
|
end
|
data/lib/pwn/ai/agent/reward.rb
CHANGED
|
@@ -143,21 +143,26 @@ module PWN
|
|
|
143
143
|
# confidence handles that in the bandit blend.
|
|
144
144
|
eng = (PWN::Env.dig(:ai, :active) if defined?(PWN::Env)).to_s.downcase
|
|
145
145
|
local = eng == 'ollama' || eng.empty?
|
|
146
|
-
if v[:source].to_s == 'heuristic'
|
|
147
|
-
|
|
146
|
+
if v[:source].to_s == 'heuristic' || v[:source].to_s.start_with?('heuristic')
|
|
147
|
+
toolbacked = Array(trace).any?
|
|
148
|
+
v[:heuristic_class] = toolbacked ? :toolbacked : :textual
|
|
149
|
+
v[:source] = :heuristic
|
|
150
|
+
v[:confidence] = if toolbacked
|
|
151
|
+
local ? 0.55 : 0.7
|
|
152
|
+
else
|
|
153
|
+
local ? 0.35 : 0.5
|
|
154
|
+
end
|
|
148
155
|
raw = v[:score].to_f
|
|
149
156
|
v[:score_raw] = raw
|
|
150
|
-
|
|
151
|
-
# and pass-through non-capped heuristics via the default arm.
|
|
152
|
-
if local && raw > 0.15 && trace.empty? && raw >= 0.6 && final.length < 400
|
|
157
|
+
if !toolbacked && local && raw > 0.15 && trace.empty? && raw >= 0.6 && final.length < 400
|
|
153
158
|
v[:score] = [raw, 0.45].min
|
|
154
159
|
v[:rationale] = "#{v[:rationale]} | P1:local_no_trace_cap"
|
|
155
|
-
elsif local && raw > 0.15 && trace.length < 2 && raw >= 0.85
|
|
156
|
-
# thin-evidence local highs: mild shrink toward 0.5
|
|
160
|
+
elsif !toolbacked && local && raw > 0.15 && trace.length < 2 && raw >= 0.85
|
|
157
161
|
v[:score] = (0.5 + ((raw - 0.5) * 0.7)).round(3).clamp(0.0, 1.0)
|
|
158
162
|
else
|
|
159
163
|
v[:score] = raw
|
|
160
164
|
end
|
|
165
|
+
|
|
161
166
|
v[:verdict] = if v[:score] >= 0.6 then :solved
|
|
162
167
|
elsif v[:score] >= 0.3 then :partial
|
|
163
168
|
else :wrong
|
|
@@ -168,59 +173,33 @@ module PWN
|
|
|
168
173
|
|
|
169
174
|
ground = verify_as_reward(final: final)
|
|
170
175
|
unless ground.nil?
|
|
171
|
-
#
|
|
172
|
-
#
|
|
173
|
-
# claim floors it at 0.6. E3.
|
|
176
|
+
# A refuted claim is negative evidence. Confirming one claim
|
|
177
|
+
# does not establish completion of the entire operator request.
|
|
174
178
|
v[:score] = [v[:score], 0.2].min if ground[:verdict] == :refuted
|
|
175
|
-
v[:score] = [v[:score], 0.6].max if ground[:verdict] == :confirmed
|
|
176
179
|
v[:grounded] = ground
|
|
177
180
|
v[:confidence] = [v[:confidence].to_f, ground[:confidence].to_f].max if ground[:confidence]
|
|
178
181
|
end
|
|
179
182
|
|
|
180
|
-
pass = final.match?(/\bPASS\b/) && !(defined?(Learning) && final.match?(Learning::FAILURE_FINAL_RX))
|
|
181
183
|
v[:judge_score] = v[:score].to_f
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
v[:verifier_verdict] = vv
|
|
186
|
-
v[:verdict_class] = taxonomy_class(opts.merge(score: v[:score], verifier_verdict: vv, request: request, final: final))
|
|
184
|
+
v[:verification] = request_verification(request: request, session_id: opts[:session_id])
|
|
185
|
+
v = resolve_outcome(outcome: v, critic_pass: opts[:critic_pass])
|
|
186
|
+
v[:verdict_class] = taxonomy_class(opts.merge(score: v[:score], verifier_verdict: v[:verifier_verdict], request: request, final: final))
|
|
187
187
|
v[:remediation_hint] = taxonomy_hint(verdict_class: v[:verdict_class])
|
|
188
|
-
prec = verifier_precedence?
|
|
189
|
-
if prec && vv == :pass
|
|
190
|
-
v[:success] = true
|
|
191
|
-
else
|
|
192
|
-
v[:success] = promote_to_success?(
|
|
193
|
-
orm: v[:source].to_s != 'heuristic' && v[:score].to_f >= 0.6,
|
|
194
|
-
verify: if ground.nil?
|
|
195
|
-
nil
|
|
196
|
-
else
|
|
197
|
-
ground[:verdict] == :confirmed
|
|
198
|
-
end,
|
|
199
|
-
critic: opts.key?(:critic_pass) ? opts[:critic_pass] : nil
|
|
200
|
-
)
|
|
201
|
-
end
|
|
202
188
|
v[:needs_spot_check] = v[:success] && v[:score].to_f >= 0.85 && (rand < 0.05)
|
|
203
189
|
v[:engine] = eng
|
|
204
190
|
v[:task_class] = request.match?(/analy[sz]e|summar|strength|weakness|fitness/i) ? 'analysis' : 'operational'
|
|
205
|
-
if pass
|
|
206
|
-
score = v[:score].to_f
|
|
207
|
-
v[:score] = [score, 0.6].max
|
|
208
|
-
v[:success] = true if prec
|
|
209
|
-
v[:verifier_verdict] ||= :pass
|
|
210
|
-
v[:verdict] = :solved
|
|
211
|
-
end
|
|
212
191
|
v[:score_components] ||= {
|
|
213
192
|
judge: v[:score].to_f,
|
|
214
|
-
overlap:
|
|
193
|
+
overlap: nil,
|
|
215
194
|
checks: 0.0,
|
|
216
|
-
weights: { overlap:
|
|
195
|
+
weights: { overlap: 0.15 }
|
|
217
196
|
}
|
|
218
|
-
v[:score_components][:weights][:overlap] = 0.0 if pass
|
|
219
197
|
if commit && defined?(Learning) && opts[:persist_components]
|
|
220
198
|
Learning.note_outcome(
|
|
221
199
|
task: request[0, 80],
|
|
222
200
|
success: v[:success],
|
|
223
201
|
score: v[:score],
|
|
202
|
+
outcome: v,
|
|
224
203
|
details: v[:score_components].to_json,
|
|
225
204
|
verifier_verdict: v[:verifier_verdict],
|
|
226
205
|
verdict_class: v[:verdict_class]
|
|
@@ -228,7 +207,7 @@ module PWN
|
|
|
228
207
|
end
|
|
229
208
|
# W3 — write Brier on every judged turn so overconfidence can
|
|
230
209
|
# throttle max_iters/critic even when plan_first never fired.
|
|
231
|
-
if commit
|
|
210
|
+
if commit && !v[:training_score].nil?
|
|
232
211
|
pred = opts[:predicted]
|
|
233
212
|
pred = Thread.current[:pwn_plan_predicted] if pred.nil?
|
|
234
213
|
pred = v[:confidence] if pred.nil?
|
|
@@ -236,10 +215,103 @@ module PWN
|
|
|
236
215
|
end
|
|
237
216
|
# P1 — sentinel stores confidence so distrust math can haircut
|
|
238
217
|
# heuristic-heavy windows differently from LLM ORM windows.
|
|
239
|
-
record_sentinel(
|
|
218
|
+
record_sentinel(v.slice(:training_score, :decision_version, :verdict, :confidence, :source).merge(proxy: opts[:proxy_ok], judge: v[:training_score])) if commit && !v[:training_score].nil?
|
|
240
219
|
v
|
|
241
220
|
rescue StandardError => e
|
|
242
|
-
{ score:
|
|
221
|
+
resolve_outcome(outcome: { score: nil, rationale: "judge error: #{e.class}", error: e.message, confidence: 0.0, source: :error })
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# The sole outcome decision. Scores are diagnostic; training_score
|
|
225
|
+
# is absent when the evaluator cannot supply a reliable label.
|
|
226
|
+
public_class_method def self.resolve_outcome(opts = {})
|
|
227
|
+
v = (opts[:outcome] || {}).dup
|
|
228
|
+
source = (v[:source] || v[:judge_source]).to_s
|
|
229
|
+
score = v[:score]
|
|
230
|
+
score = score.to_f.clamp(0.0, 1.0) unless score.nil?
|
|
231
|
+
verification = v[:verification]
|
|
232
|
+
checked = verification.is_a?(Hash) && valid_verification_checks?(checks: verification[:checks])
|
|
233
|
+
vv = if checked
|
|
234
|
+
verification[:checks].all? { |c| c[:passed] } ? :pass : :fail
|
|
235
|
+
end
|
|
236
|
+
v[:verifier_verdict] = vv
|
|
237
|
+
v[:confidence] = 1.0 if vv
|
|
238
|
+
if vv == :fail || v.dig(:grounded, :verdict).to_s == 'refuted'
|
|
239
|
+
score = [score || 0.0, 0.2].min
|
|
240
|
+
success = false
|
|
241
|
+
elsif vv == :pass && verifier_precedence?
|
|
242
|
+
score = [score || 0.0, 0.6].max
|
|
243
|
+
success = true
|
|
244
|
+
elsif score.nil? || source == 'error'
|
|
245
|
+
success = nil
|
|
246
|
+
elsif source.start_with?('heuristic')
|
|
247
|
+
success = false
|
|
248
|
+
elsif opts[:critic_pass] == false || v[:critic_pass] == false
|
|
249
|
+
if score >= 0.6
|
|
250
|
+
success = nil
|
|
251
|
+
else
|
|
252
|
+
score = [score, 0.3].min
|
|
253
|
+
success = false
|
|
254
|
+
end
|
|
255
|
+
else
|
|
256
|
+
success = score >= 0.6
|
|
257
|
+
end
|
|
258
|
+
known = !success.nil? && (!source.start_with?('heuristic') || !vv.nil?)
|
|
259
|
+
verdict = if !known then :unknown
|
|
260
|
+
elsif success then :solved
|
|
261
|
+
elsif score >= 0.3 then :partial
|
|
262
|
+
else :wrong
|
|
263
|
+
end
|
|
264
|
+
v.merge(score: score, success: success, verdict: verdict,
|
|
265
|
+
critic_pass: opts.fetch(:critic_pass, v[:critic_pass]),
|
|
266
|
+
training_score: known ? score : nil, decision_version: 1)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# Trusted host-verifier API, deliberately NOT a model-facing tool.
|
|
270
|
+
# Caller must actually check every original-request criterion. Never
|
|
271
|
+
# construct these records by parsing an assistant/tool claim of PASS.
|
|
272
|
+
public_class_method def self.record_verification(opts = {})
|
|
273
|
+
request = opts[:request].to_s
|
|
274
|
+
sid = opts[:session_id].to_s
|
|
275
|
+
checks = opts[:checks]
|
|
276
|
+
raise ArgumentError, 'nonempty complete request checks required' unless valid_verification_checks?(checks: checks)
|
|
277
|
+
|
|
278
|
+
rows = PWN::Sessions.load(session_id: sid)
|
|
279
|
+
user = rows.reverse.find { |row| row[:role].to_s == 'user' }
|
|
280
|
+
raise ArgumentError, 'verification must match the current session request' if request.empty? || !user || user[:content].to_s != request
|
|
281
|
+
|
|
282
|
+
record = { request_digest: Digest::SHA256.hexdigest(request), session_id: sid, checks: checks }
|
|
283
|
+
PWN::Sessions.append(session_id: sid, role: 'verification', content: JSON.generate(record))
|
|
284
|
+
record
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
private_class_method def self.valid_verification_checks?(opts = {})
|
|
288
|
+
checks = opts[:checks]
|
|
289
|
+
checks.is_a?(Array) && !checks.empty? && checks.all? do |check|
|
|
290
|
+
check.is_a?(Hash) && !check[:criterion].to_s.strip.empty? &&
|
|
291
|
+
[true, false].include?(check[:passed]) && !check[:evidence].to_s.strip.empty?
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
private_class_method def self.request_verification(opts = {})
|
|
296
|
+
sid = opts[:session_id].to_s
|
|
297
|
+
return nil if sid.empty?
|
|
298
|
+
|
|
299
|
+
rows = PWN::Sessions.load(session_id: sid)
|
|
300
|
+
user_idx = rows.rindex { |row| row[:role].to_s == 'user' }
|
|
301
|
+
return nil unless user_idx && rows[user_idx][:content].to_s == opts[:request].to_s
|
|
302
|
+
|
|
303
|
+
verification_idx = rows.rindex { |entry| entry[:role].to_s == 'verification' }
|
|
304
|
+
return nil unless verification_idx && verification_idx > user_idx
|
|
305
|
+
return nil if rows[(verification_idx + 1)..].any? { |entry| entry[:role].to_s == 'tool' }
|
|
306
|
+
|
|
307
|
+
row = rows[verification_idx]
|
|
308
|
+
record = JSON.parse(row[:content].to_s, symbolize_names: true)
|
|
309
|
+
return nil unless record[:session_id] == sid && record[:request_digest] == Digest::SHA256.hexdigest(opts[:request].to_s)
|
|
310
|
+
return nil unless valid_verification_checks?(checks: record[:checks])
|
|
311
|
+
|
|
312
|
+
record
|
|
313
|
+
rescue StandardError
|
|
314
|
+
nil
|
|
243
315
|
end
|
|
244
316
|
|
|
245
317
|
public_class_method def self.promote_to_success?(opts = {})
|
|
@@ -505,7 +577,7 @@ module PWN
|
|
|
505
577
|
need = SENTINEL_WINDOW - have
|
|
506
578
|
limit = (opts[:limit] || [need * 4, 200].max).to_i
|
|
507
579
|
# Prefer scored rows; fall back to success-boolean so local hosts still warm.
|
|
508
|
-
rows = Learning.outcomes(limit: limit)
|
|
580
|
+
rows = Learning.outcomes(limit: limit).select { |r| sentinel_outcome_known?(outcome: r) }
|
|
509
581
|
scored, unscored = rows.partition { |r| !r[:score].nil? }
|
|
510
582
|
ordered = scored.reverse + unscored.reverse
|
|
511
583
|
added = 0
|
|
@@ -1564,12 +1636,23 @@ module PWN
|
|
|
1564
1636
|
nil
|
|
1565
1637
|
end
|
|
1566
1638
|
|
|
1639
|
+
private_class_method def self.sentinel_outcome_known?(opts = {})
|
|
1640
|
+
row = opts[:outcome] || {}
|
|
1641
|
+
return false if row[:verdict].to_s == 'unknown'
|
|
1642
|
+
return !row[:training_score].nil? if row.key?(:training_score) || row.key?(:decision_version)
|
|
1643
|
+
|
|
1644
|
+
source = (row[:source] || row[:judge_source]).to_s
|
|
1645
|
+
return false if source.start_with?('heuristic') || source == 'error'
|
|
1646
|
+
|
|
1647
|
+
!row[:judge].nil? || !row[:score].nil? || [true, false, 'true', 'false', 'soft'].include?(row[:success])
|
|
1648
|
+
end
|
|
1649
|
+
|
|
1567
1650
|
private_class_method def self.record_sentinel(opts = {})
|
|
1568
1651
|
s = normalize_sentinel(raw: load_sentinel)
|
|
1569
1652
|
# Clamp judge to [0,1] — LLM/heuristic should already, but a bad
|
|
1570
1653
|
# write must not poison rolling means forever.
|
|
1571
1654
|
judge = opts[:judge].to_f.clamp(0.0, 1.0)
|
|
1572
|
-
entry =
|
|
1655
|
+
entry = opts.slice(:training_score, :decision_version, :verdict).merge(judge: judge, at: Time.now.utc.iso8601)
|
|
1573
1656
|
# P1 — optional per-sample confidence (heuristic < LLM ORM)
|
|
1574
1657
|
entry[:confidence] = opts[:confidence].to_f.clamp(0.0, 1.0) unless opts[:confidence].nil?
|
|
1575
1658
|
entry[:source] = opts[:source].to_s unless opts[:source].to_s.empty?
|
|
@@ -1642,10 +1725,15 @@ module PWN
|
|
|
1642
1725
|
private_class_method def self.normalize_sentinel(opts = {})
|
|
1643
1726
|
raw = opts.is_a?(Hash) && opts.key?(:raw) ? opts[:raw] : opts
|
|
1644
1727
|
s = (raw.is_a?(Hash) ? raw.dup : empty_sentinel)
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1728
|
+
window = Array(s[:window])
|
|
1729
|
+
known = window.select { |e| e.is_a?(Hash) && sentinel_outcome_known?(outcome: e) }
|
|
1730
|
+
if known.length < window.length
|
|
1731
|
+
s[:proxy_distrust] = 0.0
|
|
1732
|
+
s.delete(:distrust_at)
|
|
1733
|
+
s[:distrust_meta] = { reason: 'unknown_outcomes_removed', cleared: true }
|
|
1734
|
+
end
|
|
1735
|
+
s[:window] = known.map do |e|
|
|
1736
|
+
h = e.slice(:training_score, :decision_version, :verdict).merge(judge: e[:judge].to_f.clamp(0.0, 1.0))
|
|
1649
1737
|
h[:at] = e[:at] if e[:at]
|
|
1650
1738
|
h[:source] = e[:source].to_s unless e[:source].to_s.empty?
|
|
1651
1739
|
h[:confidence] = e[:confidence].to_f.clamp(0.0, 1.0) unless e[:confidence].nil?
|
|
@@ -1833,9 +1921,21 @@ module PWN
|
|
|
1833
1921
|
critic_pass: 'optional - critic pass value consumed by #judge',
|
|
1834
1922
|
predicted: 'optional - predicted value consumed by #judge',
|
|
1835
1923
|
proxy_ok: 'optional - proxy ok value consumed by #judge',
|
|
1836
|
-
persist_components: 'optional -
|
|
1837
|
-
|
|
1838
|
-
|
|
1924
|
+
persist_components: 'optional - persist the resolved outcome and score components'
|
|
1925
|
+
)
|
|
1926
|
+
|
|
1927
|
+
# Trusted host verifier only: actually check ALL original-request criteria.
|
|
1928
|
+
# PASS prose, exit zero, and single confirmed claims are not substitutes.
|
|
1929
|
+
#{self}.record_verification(
|
|
1930
|
+
request: 'required - exact current session user request',
|
|
1931
|
+
session_id: 'required - session holding the request and verification',
|
|
1932
|
+
checks: 'required - complete Array of {criterion:, passed: Boolean, evidence:}'
|
|
1933
|
+
)
|
|
1934
|
+
|
|
1935
|
+
# Shared verdict and training eligibility; unknown training_score is nil.
|
|
1936
|
+
#{self}.resolve_outcome(
|
|
1937
|
+
outcome: 'required - outcome Hash from the judge or trusted evaluator',
|
|
1938
|
+
critic_pass: 'optional - false records a critic disagreement'
|
|
1839
1939
|
)
|
|
1840
1940
|
|
|
1841
1941
|
# Run promote to success and return its result
|