Module: PWN::AI::Agent::Metrics
- Defined in:
- lib/pwn/ai/agent/metrics.rb
Overview
PWN::AI::Agent::Metrics is the telemetry layer of the pwn-ai learning loop. Every tool dispatch performed by PWN::AI::Agent::Loop is recorded here (name, success, duration, last error) and persisted to ~/.pwn/metrics.json.
PromptBuilder re-injects a compact effectiveness summary into the system prompt on every turn, so the model gains awareness of which tools historically succeed vs. fail on THIS host and can adapt its tool selection accordingly. This is one half of the closed feedback loop that lets pwn-ai continuously make itself smarter (the other half is PWN::AI::Agent::Learning).
PER-ENGINE SEGMENTATION
A local Ollama model and a frontier model do NOT have the same per-tool success rate — blending them mis-advises the local model about itself. Every record now also increments an :engines sub-bucket; summary/to_context accept engine: to surface only that engine's telemetry so the TOOL EFFECTIVENESS block becomes a genuine per-engine learned policy.
Constant Summary collapse
- METRICS_FILE =
File.join(Dir.home, '.pwn', 'metrics.json')
- HALF_LIFE_DAYS =
14.0- CUSUM_K =
0.15
- CUSUM_H =
0.6
- WINDOW =
30- PRM_MIN_N =
P18/P2 — rolling mean step_reward advantage for a tool. Sample-efficiency gate: < PRM_MIN_N samples → 0 (no rank noise). Shrinkage: adv *= min(1, n/PRM_FULL_N) so sparse signal cannot dominate UCB. Zero-variance windows (all +1 or all -1 from a single session) damp to 0.5×.
5- PRM_FULL_N =
20
Class Method Summary collapse
-
.advantage(opts = {}) ⇒ Object
- Supported Method Parameters
a = PWN::AI::Agent::Metrics.advantage(name: 'shell').
- .append_jsonl(opts = {}) ⇒ Object
-
.authors ⇒ Object
- Author(s)
0day Inc.
-
.calibration(opts = {}) ⇒ Object
- Supported Method Parameters
cal = PWN::AI::Agent::Metrics.calibration(engine: :ollama).
-
.calibration_green?(opts = {}) ⇒ Boolean
- Supported Method Parameters
PWN::AI::Agent::Metrics.reset.
-
.changepoints(opts = {}) ⇒ Object
- Supported Method Parameters
cps = PWN::AI::Agent::Metrics.changepoints.
-
.effective_rate(opts = {}) ⇒ Object
Blended success rate: when proxy_distrust > 0 and judge samples exist, mix judge_rate into the handler-ok rate.
- .health_line ⇒ Object
-
.help ⇒ Object
Display Usage for this Module.
-
.judge_confidence(opts = {}) ⇒ Object
P1 — mean judge confidence for a tool (nil when no samples).
-
.judge_rate(opts = {}) ⇒ Object
Mean judge score for a tool (nil when no ORM samples yet).
-
.load ⇒ Object
- Supported Method Parameters
metrics = PWN::AI::Agent::Metrics.load.
- .prm_advantage(opts = {}) ⇒ Object
- .prm_n(opts = {}) ⇒ Object
-
.proxy_trust ⇒ Object
P4 helper — Registry.rank calls this so β·advantage is scaled down when the proxy is untrustworthy.
-
.record(opts = {}) ⇒ Object
- Supported Method Parameters
PWN::AI::Agent::Metrics.record( name: 'required - tool name that was dispatched', success: 'required - Boolean, did the handler complete without error', duration: 'optional - Float seconds the dispatch took', error: 'optional - String error message when success is false', engine: 'optional - Symbol/String AI engine that chose this tool (segments telemetry)' ).
-
.record_calibration(opts = {}) ⇒ Object
- Supported Method Parameters
PWN::AI::Agent::Metrics.record_calibration(predicted:, actual:, brier:, engine:).
-
.record_judge(opts = {}) ⇒ Object
P20 — fold ORM judge (0..1) into per-tool telemetry so UCB / Thompson / advantage can prefer judge-grounded rates over the inflated handler-ok proxy when proxy_distrust is high.
-
.record_step_reward(opts = {}) ⇒ Object
P18 — called by Reward.prm after session annotate so live routing can bias toward tools that recently advanced goals (+1 step_reward).
- .record_tokens(opts = {}) ⇒ Object
- .reset ⇒ Object
- .routing(opts = {}) ⇒ Object
-
.save(opts = {}) ⇒ Object
- Supported Method Parameters
PWN::AI::Agent::Metrics.save( metrics: 'required - Hash returned by .load / mutated in place' ).
- .scale_prediction(opts = {}) ⇒ Object
- .scoreboard(opts = {}) ⇒ Object
- .snapshot(opts = {}) ⇒ Object
-
.summary(opts = {}) ⇒ Object
- Supported Method Parameters
rows = PWN::AI::Agent::Metrics.summary( limit: 'optional - cap number of tools returned (default 25)', engine: 'optional - only that engine's sub-bucket (falls back to global when absent)' ).
-
.thompson(opts = {}) ⇒ Object
- Supported Method Parameters
p = PWN::AI::Agent::Metrics.thompson(name: 'shell').
-
.to_context(opts = {}) ⇒ Object
- Supported Method Parameters
ctx = PWN::AI::Agent::Metrics.to_context( limit: 'optional - cap number of tools included (default 8)', engine: 'optional - restrict to one engine's telemetry' ).
- .ucb(opts = {}) ⇒ Object
- .usage(opts = {}) ⇒ Object
Class Method Details
.advantage(opts = {}) ⇒ Object
- Supported Method Parameters
a = PWN::AI::Agent::Metrics.advantage(name: 'shell')
C1 — tool.success_rate − global_rate over the rolling window.
310 311 312 313 314 315 316 317 318 319 320 321 322 323 |
# File 'lib/pwn/ai/agent/metrics.rb', line 310 public_class_method def self.advantage(opts = {}) data = load[:tools] || {} t = data[opts[:name].to_s.to_sym] return 0.0 unless t # P20 — local/global from effective_rate so bandit tracks judge # when the handler-ok proxy is hacked (proxy_distrust high). local = effective_rate(name: opts[:name]) rates = data.keys.map { |k| effective_rate(name: k) } global = rates.empty? ? 0.5 : (rates.sum / rates.length) (local - global).round(3) rescue StandardError 0.0 end |
.append_jsonl(opts = {}) ⇒ Object
106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 |
# File 'lib/pwn/ai/agent/metrics.rb', line 106 public_class_method def self.append_jsonl(opts = {}) path = File.join(Dir.home, '.pwn', 'logs', 'tool_metrics.jsonl') FileUtils.mkdir_p(File.dirname(path)) row = { ts: Time.now.utc.iso8601, session: Thread.current[:pwn_session_id], tool: opts[:name], latency_ms: (opts[:duration].to_f * 1000).round, timeout_used: opts[:timeout], outcome: opts[:success] ? 'ok' : 'err', error_class: opts[:error].to_s.split(':').first, bytes_out: opts[:bytes_out].to_i } File.open(path, 'a') do |f| f.flock(File::LOCK_EX) f.puts(JSON.generate(row)) end row rescue StandardError nil end |
.authors ⇒ Object
- Author(s)
0day Inc. [email protected]
716 717 718 |
# File 'lib/pwn/ai/agent/metrics.rb', line 716 public_class_method def self. "AUTHOR(S):\n 0day Inc. <[email protected]>\n" end |
.calibration(opts = {}) ⇒ Object
- Supported Method Parameters
cal = PWN::AI::Agent::Metrics.calibration(engine: :ollama)
529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 |
# File 'lib/pwn/ai/agent/metrics.rb', line 529 public_class_method def self.calibration(opts = {}) store = load[:calibration] || {} buckets = if opts.key?(:engine) && !opts[:engine].to_s.empty? [store[opts[:engine].to_s.to_sym]].compact else store.values end c = buckets.each_with_object({ n: 0, brier_sum: 0.0, p_sum: 0.0, a_sum: 0.0 }) do |b, acc| next unless b.is_a?(Hash) acc[:n] += b[:n].to_i acc[:brier_sum] += b[:brier_sum].to_f acc[:p_sum] += b[:p_sum].to_f acc[:a_sum] += b[:a_sum].to_f end return { n: 0, brier: nil } unless c[:n].positive? n = c[:n].to_f { n: c[:n], brier: (c[:brier_sum] / n).round(4), mean_predicted: (c[:p_sum] / n).round(3), mean_actual: (c[:a_sum] / n).round(3), overconfidence: ((c[:p_sum] - c[:a_sum]) / n).round(3) } end |
.calibration_green?(opts = {}) ⇒ Boolean
- Supported Method Parameters
PWN::AI::Agent::Metrics.reset
553 554 555 556 557 558 559 560 561 |
# File 'lib/pwn/ai/agent/metrics.rb', line 553 public_class_method def self.calibration_green?(opts = {}) cal = calibration(engine: opts[:engine]) return false if cal[:n].to_i < 8 return false if cal[:overconfidence].nil? cal[:overconfidence].to_f <= 0.08 rescue StandardError false end |
.changepoints(opts = {}) ⇒ Object
- Supported Method Parameters
cps = PWN::AI::Agent::Metrics.changepoints
E1 — tools whose CUSUM tripped (success_rate regime change). The caller (Mistakes.record / Curriculum) triggers extro_snapshot + correlate on these so a Mistake caused by env drift is tagged cause: :env_drift and does NOT count toward [REPEATING].
493 494 495 496 497 498 499 500 501 502 503 504 |
# File 'lib/pwn/ai/agent/metrics.rb', line 493 public_class_method def self.changepoints(opts = {}) within = (opts[:within_secs] || 3_600).to_i now = Time.now.utc (load[:tools] || {}).filter_map do |name, t| cp = t[:changepoint_at] next unless cp && (now - Time.parse(cp)) < within { name: name.to_s, at: cp, window_rate: Array(t[:window]).sum.to_f / [Array(t[:window]).length, 1].max } end rescue StandardError [] end |
.effective_rate(opts = {}) ⇒ Object
Blended success rate: when proxy_distrust > 0 and judge samples exist, mix judge_rate into the handler-ok rate. distrust=1 → pure judge (or 0.5 if no judge data). distrust=0 → pure proxy.
456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 |
# File 'lib/pwn/ai/agent/metrics.rb', line 456 public_class_method def self.effective_rate(opts = {}) name = opts[:name].to_s data = load[:tools] || {} t = data[name.to_sym] return 0.5 unless t calls = [t[:calls].to_f, 1.0].max proxy = t[:ok].to_f / calls win = Array(t[:window]) proxy = win.sum.to_f / win.length if win.length >= 3 distrust = 1.0 - proxy_trust jrate = judge_rate(name: name) if distrust > 0.05 && !jrate.nil? # P1 — scale judge weight by judge confidence so a thin local # heuristic ORM cannot fully replace proxy when distrust is high. # effective_distrust = distrust * mean(confidence), floor 0.15 when # we do have judge samples so the signal still moves the needle. jconf = judge_confidence(name: name) jconf = 0.7 if jconf.nil? eff_d = (distrust * jconf).clamp(0.0, 1.0) eff_d = [eff_d, 0.15].max if jconf >= 0.3 ((proxy * (1.0 - eff_d)) + (jrate * eff_d)).clamp(0.0, 1.0).round(3) else proxy.round(3) end rescue StandardError 0.5 end |
.health_line ⇒ Object
617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 |
# File 'lib/pwn/ai/agent/metrics.rb', line 617 public_class_method def self.health_line board = scoreboard gap = nil if defined?(Reward) && Reward.respond_to?(:sentinel) s = Reward.sentinel gap = s[:gap_proxy_judge] if s.is_a?(Hash) end trend = if defined?(Curriculum) && Curriculum.respond_to?(:repeating_trend) Curriculum.repeating_trend else {} end traj = (Reward.generator_mix[:trajectory_fraction] if defined?(Reward) && Reward.respond_to?(:generator_mix)) parked = (Mistakes.operator_inbox(limit: 50)[:count] if defined?(Mistakes) && Mistakes.respond_to?(:operator_inbox)) "HEALTH tool_ok=#{board[:tool_ok] || '-'} task_ok=#{board[:task_ok] || '-'} " \ "judge_ok=#{board[:judge_ok] || '-'} pred=#{board[:mean_predicted] || '-'} " \ "judge-proxy-gap=#{gap || '-'} repeating=#{trend[:status] || '-'} " \ "w1-traj=#{traj || '-'} parked-needs-human=#{parked || '-'}\n" rescue StandardError '' end |
.help ⇒ Object
Display Usage for this Module
722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 |
# File 'lib/pwn/ai/agent/metrics.rb', line 722 public_class_method def self.help puts "USAGE: # Run load and return its result #{self}.load # Run save and return its result #{self}.save( metrics: 'required - Hash returned by .load / mutated in place' ) # Run record and return its result #{self}.record( name: 'required - tool name that was dispatched', success: 'required - Boolean, did the handler complete without error', duration: 'optional - Float seconds the dispatch took (wall time)', error: 'optional - String error message when success is false', engine: 'optional - Symbol/String AI engine that chose this tool (segments telemetry)' ) # Append one JSONL telemetry row under ~/.pwn/logs/tool_metrics.jsonl. #{self}.append_jsonl( name: 'required - tool name', success: 'optional - Boolean outcome', duration: 'optional - Float seconds the dispatch took (wall time)', error: 'optional - error string', engine: 'optional - engine name', timeout: 'optional - timeout used', bytes_out: 'optional - output byte count' ) # Run summary and return its result #{self}.summary( limit: 'optional - cap number of tools returned (default 25)', engine: 'optional - only that engine\\s sub-bucket (falls back to global when absent)' ) # Run to context and return its result #{self}.to_context( limit: 'optional - cap number of tools included (default 8)', engine: 'optional - restrict to one engine\\s telemetry' ) # P4 helper — Registry.rank calls this so β·advantage is scaled down #{self}.proxy_trust # Run ucb and return its result #{self}.ucb( name: 'optional - binary or identifier name', c: 'optional - c value consumed by #ucb' ) # Run thompson and return its result #{self}.thompson( name: 'optional - binary or identifier name' ) # Run advantage and return its result #{self}.advantage( name: 'optional - binary or identifier name' ) # Run prm advantage and return its result #{self}.prm_advantage( name: 'optional - binary or identifier name' ) # Run prm n and return its result #{self}.prm_n( name: 'optional - binary or identifier name' ) # P18 — called by Reward.prm after session annotate so live routing #{self}.record_step_reward( name: 'required - binary or identifier name', reward: 'optional - reward value consumed by #record_step_reward' ) # P20 — fold ORM judge (0..1) into per-tool telemetry so UCB / #{self}.record_judge( name: 'required - binary or identifier name', score: 'optional - score value consumed by #record_judge', confidence: 'optional - confidence value consumed by #record_judge', source: 'optional - source value consumed by #record_judge' ) # P1 — mean judge confidence for a tool (nil when no samples) #{self}.judge_confidence( name: 'optional - binary or identifier name' ) # Mean judge score for a tool (nil when no ORM samples yet) #{self}.judge_rate( name: 'optional - binary or identifier name' ) # Blended success rate: when proxy_distrust > 0 and judge samples #{self}.effective_rate( name: 'optional - binary or identifier name' ) # Run changepoints and return its result #{self}.changepoints( cause: 'optional - :env_drift and does NOT count toward [REPEATING].', within_secs: 'optional - within secs value consumed by #changepoints' ) # W3 — plan_first emits p(success); Loop.run calls this with the #{self}.record_calibration( engine: 'optional - engine value consumed by #record_calibration (defaults to :global))', brier: 'optional - brier value consumed by #record_calibration', predicted: 'optional - predicted value consumed by #record_calibration', actual: 'optional - actual value consumed by #record_calibration' ) # Run calibration and return its result #{self}.calibration( engine: 'required - engine value consumed by #calibration' ) # Run calibration green and return its result #{self}.calibration_green?( engine: 'optional - engine value consumed by #calibration_green?' ) # Run scale prediction and return its result #{self}.scale_prediction( predicted: 'optional - predicted value consumed by #scale_prediction', engine: 'optional - engine value consumed by #scale_prediction' ) # Run scoreboard and return its result #{self}.scoreboard( engine: 'optional - engine value consumed by #scoreboard' ) # Run health line and return its result #{self}.health_line # Run reset and return its result #{self}.reset # Snapshot success rates per judge source for the LEARNING block. #{self}.snapshot( day: 'optional - reserved day key for rotation' ) # Record token/cost for a model call. #{self}.record_tokens( tokens: 'optional - integer token count', cost: 'optional - Float USD cost', model: 'optional - model id string' ) # Return cumulative token/cost usage. #{self}.usage( session_id: 'optional - unused reserved session id' ) # Return ai.routing fallback chain from pwn.yaml. #{self}.routing( n: 'optional - unused reserved index' ) # Print the AUTHOR(S) string for this module. #{self}.authors " constants.sort end |
.judge_confidence(opts = {}) ⇒ Object
P1 — mean judge confidence for a tool (nil when no samples).
415 416 417 418 419 420 421 422 423 424 425 |
# File 'lib/pwn/ai/agent/metrics.rb', line 415 public_class_method def self.judge_confidence(opts = {}) t = (load[:tools] || {})[opts[:name].to_s.to_sym] return nil unless t win = Array(t[:judge_conf_window]) return nil if win.empty? (win.sum.to_f / win.length).round(3) rescue StandardError nil end |
.judge_rate(opts = {}) ⇒ Object
Mean judge score for a tool (nil when no ORM samples yet). When per-sample sources exist, LLM ORM outweighs heuristic overlap so the proxy haircut tracks the outcome model.
430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 |
# File 'lib/pwn/ai/agent/metrics.rb', line 430 public_class_method def self.judge_rate(opts = {}) t = (load[:tools] || {})[opts[:name].to_s.to_sym] return nil unless t win = Array(t[:judge_window]) return nil if win.empty? src = Array(t[:judge_src_window]) if src.length == win.length && defined?(Reward) && Reward.respond_to?(:judge_sample_weight) num = 0.0 den = 0.0 win.each_with_index do |score, i| wt = Reward.judge_sample_weight(source: src[i]).to_f num += score.to_f * wt den += wt end return (num / den).round(3) if den.positive? end (win.sum.to_f / win.length).round(3) rescue StandardError nil end |
.load ⇒ Object
- Supported Method Parameters
metrics = PWN::AI::Agent::Metrics.load
40 41 42 43 44 45 46 47 |
# File 'lib/pwn/ai/agent/metrics.rb', line 40 public_class_method def self.load FileUtils.mkdir_p(File.dirname(METRICS_FILE)) return { tools: {}, updated_at: nil } unless File.exist?(METRICS_FILE) JSON.parse(File.read(METRICS_FILE), symbolize_names: true) rescue StandardError { tools: {}, updated_at: nil } end |
.prm_advantage(opts = {}) ⇒ Object
333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 |
# File 'lib/pwn/ai/agent/metrics.rb', line 333 public_class_method def self.prm_advantage(opts = {}) data = load[:tools] || {} t = data[opts[:name].to_s.to_sym] return 0.0 unless t win = Array(t[:prm_window]) n = win.length return 0.0 if n < PRM_MIN_N mean = win.sum.to_f / n globals = data.values.map { |v| Array(v[:prm_window]) }.select { |w| w.length >= PRM_MIN_N } gmean = if globals.empty? 0.0 else all = globals.flatten all.sum.to_f / all.length end adv = mean - gmean # shrinkage toward 0 until PRM_FULL_N shrink = [n.to_f / PRM_FULL_N, 1.0].min # variance damp: if all equal, halve influence uniq = win.uniq var_damp = uniq.length <= 1 ? 0.5 : 1.0 (adv * shrink * var_damp).round(3) rescue StandardError 0.0 end |
.prm_n(opts = {}) ⇒ Object
361 362 363 364 365 366 367 368 |
# File 'lib/pwn/ai/agent/metrics.rb', line 361 public_class_method def self.prm_n(opts = {}) t = (load[:tools] || {})[opts[:name].to_s.to_sym] return 0 unless t Array(t[:prm_window]).length rescue StandardError 0 end |
.proxy_trust ⇒ Object
P4 helper — Registry.rank calls this so β·advantage is scaled down when the proxy is untrustworthy.
257 258 259 260 261 262 |
# File 'lib/pwn/ai/agent/metrics.rb', line 257 public_class_method def self.proxy_trust d = defined?(Reward) && Reward.respond_to?(:proxy_distrust) ? Reward.proxy_distrust : 0.0 (1.0 - d.to_f).clamp(0.0, 1.0) rescue StandardError 1.0 end |
.record(opts = {}) ⇒ Object
- Supported Method Parameters
PWN::AI::Agent::Metrics.record( name: 'required - tool name that was dispatched', success: 'required - Boolean, did the handler complete without error', duration: 'optional - Float seconds the dispatch took', error: 'optional - String error message when success is false', engine: 'optional - Symbol/String AI engine that chose this tool (segments telemetry)' )
83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 |
# File 'lib/pwn/ai/agent/metrics.rb', line 83 public_class_method def self.record(opts = {}) name = opts[:name].to_s success = opts[:success] ? true : false duration = opts[:duration].to_f error = opts[:error] engine = opts[:engine].to_s return if name.empty? metrics = load metrics[:tools] ||= {} key = name.to_sym t = metrics[:tools][key] ||= blank_bucket bump(bucket: t, success: success, duration: duration, error: error) unless engine.empty? t[:engines] ||= {} e = t[:engines][engine.to_sym] ||= blank_bucket bump(bucket: e, success: success, duration: duration, error: error) end save(metrics: metrics) append_jsonl(opts.merge(name: name, success: success, duration: duration, error: error, engine: engine)) t end |
.record_calibration(opts = {}) ⇒ Object
- Supported Method Parameters
PWN::AI::Agent::Metrics.record_calibration(predicted:, actual:, brier:, engine:)
W3 — plan_first emits p(success); Loop.run calls this with the realised outcome. Tracked per-engine so calibration of the local LoRA vs frontier is comparable.
513 514 515 516 517 518 519 520 521 522 523 524 |
# File 'lib/pwn/ai/agent/metrics.rb', line 513 public_class_method def self.record_calibration(opts = {}) m = load m[:calibration] ||= {} eng = (opts[:engine] || :global).to_s.to_sym c = m[:calibration][eng] ||= { n: 0, brier_sum: 0.0, p_sum: 0.0, a_sum: 0.0 } c[:n] += 1 c[:brier_sum] += opts[:brier].to_f c[:p_sum] += opts[:predicted].to_f c[:a_sum] += opts[:actual].to_f save(metrics: m) c end |
.record_judge(opts = {}) ⇒ Object
P20 — fold ORM judge (0..1) into per-tool telemetry so UCB / Thompson / advantage can prefer judge-grounded rates over the inflated handler-ok proxy when proxy_distrust is high.
392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 |
# File 'lib/pwn/ai/agent/metrics.rb', line 392 public_class_method def self.record_judge(opts = {}) name = opts[:name].to_s return if name.empty? score = opts[:score].to_f.clamp(0.0, 1.0) conf = opts.key?(:confidence) ? opts[:confidence].to_f.clamp(0.0, 1.0) : 0.7 src = opts[:source].to_s src = 'heuristic' if src.empty? m = load m[:tools] ||= {} t = m[:tools][name.to_sym] ||= blank_bucket t[:judge_window] = (Array(t[:judge_window]) + [score]).last(40) t[:judge_conf_window] = (Array(t[:judge_conf_window]) + [conf]).last(40) t[:judge_src_window] = (Array(t[:judge_src_window]) + [src]).last(40) t[:judge_sum] = t[:judge_window].sum.to_f t[:judge_n] = t[:judge_window].length save(metrics: m) t rescue StandardError nil end |
.record_step_reward(opts = {}) ⇒ Object
P18 — called by Reward.prm after session annotate so live routing can bias toward tools that recently advanced goals (+1 step_reward).
372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 |
# File 'lib/pwn/ai/agent/metrics.rb', line 372 public_class_method def self.record_step_reward(opts = {}) name = opts[:name].to_s return if name.empty? rew = opts[:reward].to_f.clamp(-1.0, 1.0) m = load m[:tools] ||= {} t = m[:tools][name.to_sym] ||= blank_bucket t[:prm_window] = (Array(t[:prm_window]) + [rew]).last(40) t[:prm_sum] = t[:prm_window].sum t[:prm_n] = t[:prm_window].length save(metrics: m) t rescue StandardError nil end |
.record_tokens(opts = {}) ⇒ Object
172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 |
# File 'lib/pwn/ai/agent/metrics.rb', line 172 public_class_method def self.record_tokens(opts = {}) n = opts[:tokens].to_i cost = opts[:cost].to_f model = opts[:model].to_s m = load m[:usage] ||= { tokens: 0, cost: 0.0, calls: 0, by_model: {} } m[:usage][:tokens] += n m[:usage][:cost] += cost m[:usage][:calls] += 1 m[:usage][:by_model][model] ||= { tokens: 0, cost: 0.0, calls: 0 } m[:usage][:by_model][model][:tokens] += n m[:usage][:by_model][model][:cost] += cost m[:usage][:by_model][model][:calls] += 1 save(metrics: m) m[:usage] end |
.reset ⇒ Object
639 640 641 642 |
# File 'lib/pwn/ai/agent/metrics.rb', line 639 public_class_method def self.reset FileUtils.rm_f(METRICS_FILE) { tools: {}, updated_at: nil } end |
.routing(opts = {}) ⇒ Object
194 195 196 197 198 199 200 |
# File 'lib/pwn/ai/agent/metrics.rb', line 194 public_class_method def self.routing(opts = {}) _n = opts[:n] chain = (PWN::Env.dig(:ai, :routing) if defined?(PWN::Env)) Array(chain) rescue StandardError [] end |
.save(opts = {}) ⇒ Object
- Supported Method Parameters
PWN::AI::Agent::Metrics.save( metrics: 'required - Hash returned by .load / mutated in place' )
54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 |
# File 'lib/pwn/ai/agent/metrics.rb', line 54 public_class_method def self.save(opts = {}) metrics = opts[:metrics] ||= { tools: {} } metrics[:updated_at] = Time.now.utc.iso8601 FileUtils.mkdir_p(File.dirname(METRICS_FILE)) # 4.4 — flock + atomic rename path = METRICS_FILE tmp = File.join(File.dirname(path), ".#{File.basename(path)}.#{Process.pid}.tmp") body = JSON.pretty_generate(metrics) File.open(tmp, File::WRONLY | File::CREAT | File::TRUNC, 0o644) do |f| f.flock(File::LOCK_EX) f.write(body) f.flush f.fsync end File.rename(tmp, path) metrics ensure FileUtils.rm_f(tmp) if defined?(tmp) && tmp && File.exist?(tmp) end |
.scale_prediction(opts = {}) ⇒ Object
563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 |
# File 'lib/pwn/ai/agent/metrics.rb', line 563 public_class_method def self.scale_prediction(opts = {}) p = opts[:predicted].to_f.clamp(0.0, 1.0) cal = calibration(engine: opts[:engine]) return p.round(3) if cal[:n].to_i < 8 || cal[:overconfidence].nil? oc = cal[:overconfidence].to_f return p.round(3) if oc <= 0.02 temp = (1.0 + (4.0 * oc.clamp(0.0, 1.0))).clamp(1.0, 8.0) q = p.clamp(1.0e-6, 1.0 - 1.0e-6) logit = Math.log(q / (1.0 - q)) scaled = 1.0 / (1.0 + Math.exp(-(logit / temp))) scaled.round(3) rescue StandardError opts[:predicted].to_f.clamp(0.0, 1.0) end |
.scoreboard(opts = {}) ⇒ Object
580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 |
# File 'lib/pwn/ai/agent/metrics.rb', line 580 public_class_method def self.scoreboard(opts = {}) rows = summary(limit: 50) tool_ok = if rows.empty? nil else w = rows.sum { |r| r[:calls].to_f } w.positive? ? (rows.sum { |r| r[:success_rate].to_f * r[:calls].to_i } / w).round(3) : nil end task_ok = nil if defined?(Learning) && Learning.respond_to?(:outcomes) rec = Learning.outcomes(limit: 200).reject do |r| r[:success].nil? || r[:status].to_s == 'unverified' || r[:verdict].to_s == 'unknown' || (r[:decision_version] && r[:training_score].nil?) end if rec.any? hits = rec.count { |r| r[:success] == true } task_ok = (hits.to_f / rec.length).round(3) end end cal = calibration(engine: opts[:engine]) judge_ok = cal[:mean_actual] if judge_ok.nil? && defined?(Reward) && Reward.respond_to?(:sentinel) s = Reward.sentinel judge_ok = s[:judge] if s.is_a?(Hash) end { tool_ok: tool_ok, task_ok: task_ok, judge_ok: judge_ok, mean_predicted: cal[:mean_predicted], overconfidence: cal[:overconfidence], n: cal[:n].to_i } rescue StandardError { tool_ok: nil, task_ok: nil, judge_ok: nil, n: 0 } end |
.snapshot(opts = {}) ⇒ Object
159 160 161 162 163 164 165 166 167 168 169 170 |
# File 'lib/pwn/ai/agent/metrics.rb', line 159 public_class_method def self.snapshot(opts = {}) _day = opts[:day] learn = defined?(Learning) ? Learning.stats : {} { success_rate: learn[:success_rate], success_rate_orm: learn[:success_rate_orm], success_rate_heur: learn[:success_rate_heur], brier: (learn.dig(:calibration, :brier) if learn.is_a?(Hash)), proxy_distrust: learn[:proxy_distrust], tools: summary(limit: 8) } end |
.summary(opts = {}) ⇒ Object
- Supported Method Parameters
rows = PWN::AI::Agent::Metrics.summary( limit: 'optional - cap number of tools returned (default 25)', engine: 'optional - only that engine's sub-bucket (falls back to global when absent)' )
134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 |
# File 'lib/pwn/ai/agent/metrics.rb', line 134 public_class_method def self.summary(opts = {}) limit = opts[:limit] || 25 engine = opts[:engine].to_s tools = load[:tools] || {} rows = tools.map do |name, t| b = engine.empty? ? t : (t.dig(:engines, engine.to_sym) || t) calls = b[:calls].to_i ok = b[:ok].to_i rate = calls.positive? ? (ok.to_f / calls).round(3) : 0.0 avg = calls.positive? ? (b[:total_duration].to_f / calls).round(3) : 0.0 { name: name.to_s, calls: calls, success_rate: rate, judge_rate: judge_rate(name: name), effective_rate: effective_rate(name: name), avg_duration: avg, last_error: b[:last_error], last_at: b[:last_at] } end rows.reject { |r| r[:calls].zero? } .sort_by { |r| [-r[:calls], -r[:success_rate]] }.first(limit) end |
.thompson(opts = {}) ⇒ Object
- Supported Method Parameters
p = PWN::AI::Agent::Metrics.thompson(name: 'shell')
C1 — Thompson sample from Beta(ok+1, fail+1). Naturally balances exploit/explore; used by Registry.rank as the tie-breaker.
284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 |
# File 'lib/pwn/ai/agent/metrics.rb', line 284 public_class_method def self.thompson(opts = {}) t = (load[:tools] || {})[opts[:name].to_s.to_sym] || blank_bucket # P20 — when judge samples exist and distrust > 0, tilt Beta toward # judge_rate so Thompson explore/exploit tracks ORM not handler-ok. ok = t[:ok].to_f fail = t[:fail].to_f jn = Array(t[:judge_window]).length if jn >= 3 && proxy_trust < 0.95 jr = judge_rate(name: opts[:name]).to_f # pseudo-counts from judge window, mixed by distrust d = (1.0 - proxy_trust).clamp(0.0, 1.0) jok = jr * jn jfail = (1.0 - jr) * jn ok = (ok * (1.0 - d)) + (jok * d) fail = (fail * (1.0 - d)) + (jfail * d) end beta_sample(alpha: ok + 1.0, beta: fail + 1.0) rescue StandardError 0.5 end |
.to_context(opts = {}) ⇒ Object
- Supported Method Parameters
ctx = PWN::AI::Agent::Metrics.to_context( limit: 'optional - cap number of tools included (default 8)', engine: 'optional - restrict to one engine's telemetry' )
208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 |
# File 'lib/pwn/ai/agent/metrics.rb', line 208 public_class_method def self.to_context(opts = {}) limit = opts[:limit] || 8 engine = opts[:engine] rows = summary(limit: limit, engine: engine) return '' if rows.empty? # P4 — when Reward.sentinel says proxy is hacked, haircut displayed # success rates so the model does not trust the lie in the prompt. distrust = defined?(Reward) && Reward.respond_to?(:proxy_distrust) ? Reward.proxy_distrust : 0.0 scope = engine.to_s.empty? ? 'historical' : "engine=#{engine}" scope = "#{scope}, proxy_distrust=#{distrust.round(2)}" if distrust.positive? lines = rows.map do |r| # P20 — display effective_rate (judge-blended) when available; # fall back to distrust haircut on raw proxy. rate = r[:success_rate].to_f eff = r[:effective_rate] adj = if eff eff.to_f else rate - ((rate - 0.5) * distrust) end err = r[:last_error] ? " last_err=#{r[:last_error][0, 60]}" : '' jtag = r[:judge_rate] ? " judge=#{(r[:judge_rate].to_f * 100).round(1)}%" : '' tag = distrust.positive? || r[:judge_rate] ? ' (adj)' : '' " - #{r[:name]}: calls=#{r[:calls]} success=#{(adj * 100).round(1)}%#{tag}#{jtag} avg=#{r[:avg_duration]}s#{err}" end warn_line = distrust.positive? ? "WARNING: reward proxy diverges from judge — success rates haircut by distrust=#{distrust.round(2)}; prefer judge-scored exemplars over raw rates.\n" : '' # P0 ops — surface W1 generator_mix when diet is unhealthy so the # online controller (and the model) prefer underfilled sources. mix_line = '' if defined?(Reward) && Reward.respond_to?(:generator_mix) begin m = Reward.generator_mix unless m[:healthy] mix_line = "W1 MIX: n=#{m[:n]} traj=#{m[:trajectory_fraction]} " \ "urgent=#{Array(m[:urgent]).join(',')} " \ "suppress=#{Array(m[:suppress]).join(',')} " \ "rec=#{m[:recommendation]}\n" end rescue StandardError mix_line = '' end end health = health_line "#{warn_line}#{mix_line}#{health}TOOL EFFECTIVENESS (#{scope}, adapt tool choice accordingly)\n#{lines.join("\n")}\n\n" end |
.ucb(opts = {}) ⇒ Object
264 265 266 267 268 269 270 271 272 273 274 275 276 |
# File 'lib/pwn/ai/agent/metrics.rb', line 264 public_class_method def self.ucb(opts = {}) name = opts[:name].to_s c = (opts[:c] || 1.4).to_f data = load[:tools] || {} t = data[name.to_sym] || blank_bucket n = [t[:calls].to_f, 1.0].max total = [data.values.sum { |v| v[:calls].to_f }, 1.0].max # P20 — mean from effective_rate (judge-blended when distrust high) mean = effective_rate(name: name) mean + (c * Math.sqrt(Math.log(total) / n)) rescue StandardError 1.0 end |
.usage(opts = {}) ⇒ Object
189 190 191 192 |
# File 'lib/pwn/ai/agent/metrics.rb', line 189 public_class_method def self.usage(opts = {}) _sid = opts[:session_id] load[:usage] || { tokens: 0, cost: 0.0, calls: 0 } end |