ask-decisions 0.1.1

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.
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Decisions
5
+ # Accumulates questions and executes them in a single API call.
6
+ #
7
+ # Jev evaluates all questions in one request in parallel, and adding questions
8
+ # barely changes latency or cost. This batching discipline is the core
9
+ # ergonomic of the decision layer — send every question the turn might need
10
+ # in one call, let Ruby ignore the answers it doesn't reach.
11
+ #
12
+ # result = Ask::Decisions.batch(state: "...") do |b|
13
+ # b.ask("route", Ask::Decision::Choice.new(...))
14
+ # b.ask("urgent", Ask::Decision::Noul.new(...))
15
+ # b.ask("quality", Ask::Decision::Score.new(...))
16
+ # end
17
+ # result["route"].choice
18
+ #
19
+ class Batcher
20
+ attr_reader :provider, :state, :model, :questions
21
+
22
+ def initialize(provider, state:, model: nil)
23
+ @provider = provider
24
+ @state = state
25
+ @model = model
26
+ @questions = {}
27
+ end
28
+
29
+ # Add a question to the batch.
30
+ #
31
+ # @param id [String] the question id (used to access the answer)
32
+ # @param decision [Ask::Decision::Choice, Ask::Decision::Score, Ask::Decision::Noul]
33
+ # @return [self]
34
+ def ask(id, decision)
35
+ @questions[id.to_s] = decision
36
+ self
37
+ end
38
+
39
+ # Execute all accumulated questions in a single API call.
40
+ #
41
+ # @return [Ask::DecisionResult::Batch]
42
+ def execute
43
+ return empty_result if @questions.empty?
44
+
45
+ @provider.evaluate(
46
+ state: @state,
47
+ decisions: @questions,
48
+ model: @model
49
+ )
50
+ end
51
+
52
+ private
53
+
54
+ def empty_result
55
+ Ask::DecisionResult::Batch.new(answers: {})
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Decisions
5
+ # Caches DecisionProvider results keyed on (state, questions).
6
+ # Jev is self-consistent — identical inputs return identical outputs —
7
+ # so a cache is safe and prevents redundant calls when the same decision
8
+ # is made multiple times in a turn (e.g. guard + rerun).
9
+ #
10
+ # cached = Ask::Decisions::Cache.new(provider, ttl: 120)
11
+ # cached.evaluate(state: s, decisions: qs) # hits the API
12
+ # cached.evaluate(state: s, decisions: qs) # returns the cached result
13
+ #
14
+ class Cache
15
+ attr_reader :provider, :ttl
16
+
17
+ def initialize(provider, ttl: 120)
18
+ @provider = provider
19
+ @ttl = ttl
20
+ @store = {}
21
+ end
22
+
23
+ def evaluate(state:, decisions:, model: nil)
24
+ key = cache_key(state, decisions, model)
25
+ entry = @store[key]
26
+
27
+ if entry && !expired?(entry)
28
+ return entry[:result]
29
+ end
30
+
31
+ result = @provider.evaluate(state: state, decisions: decisions, model: model)
32
+ @store[key] = { result: result, timestamp: Time.now.to_f }
33
+ result
34
+ end
35
+
36
+ def clear
37
+ @store.clear
38
+ nil
39
+ end
40
+
41
+ def size
42
+ @store.size
43
+ end
44
+
45
+ private
46
+
47
+ def cache_key(state, decisions, model)
48
+ digest = ::JSON.generate({
49
+ state: state,
50
+ decisions: decisions.transform_values(&:to_h),
51
+ model: model
52
+ })
53
+ [state_hash(digest), model].join(":")
54
+ end
55
+
56
+ def state_hash(data)
57
+ # Use a simple digest for cache keys — not cryptographic, just dedup.
58
+ require "digest" unless defined?(::Digest)
59
+ Digest::MD5.hexdigest(data)
60
+ end
61
+
62
+ def expired?(entry)
63
+ (Time.now.to_f - entry[:timestamp]) > @ttl
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Decisions
5
+ # Runs a set of test cases against a decision provider and produces
6
+ # a calibration report. Used to measure whether Jev's confidence is
7
+ # trustworthy on YOUR decisions.
8
+ #
9
+ # harness = Ask::Decisions::CalibrationHarness.new(provider)
10
+ #
11
+ # harness.add_case(
12
+ # id: "urgent_message",
13
+ # state: "Help! My server is down!",
14
+ # decisions: { "urgent" => Ask::Decision::Noul.new(instructions: "Is this urgent?") },
15
+ # expected: { "urgent" => { noul_above: 0.7 } }
16
+ # )
17
+ #
18
+ # report = harness.run
19
+ # puts report
20
+ #
21
+ class CalibrationHarness
22
+ def initialize(provider)
23
+ @provider = provider
24
+ @cases = []
25
+ end
26
+
27
+ # Add a test case.
28
+ #
29
+ # @param id [String] a descriptive id for this case
30
+ # @param state [String, Hash] the state to evaluate
31
+ # @param decisions [Hash] the questions to ask
32
+ # @param expected [Hash] expected outcomes for assertions:
33
+ # - { choice_is: "value" } — check choice answer
34
+ # - { noul_above: 0.7 } — check noul is above threshold
35
+ # - { noul_below: 0.3 } — check noul is below threshold
36
+ # - { score_above: 2.0 } — check score is above threshold
37
+ # - { confidence_above: 0.7 } — check confidence
38
+ # @param runs [Integer] how many times to run this case (for variance)
39
+ def add_case(id:, state:, decisions:, expected: {}, runs: 1)
40
+ @cases << { id: id, state: state, decisions: decisions, expected: expected, runs: runs }
41
+ end
42
+
43
+ # Run all test cases and produce a calibration report.
44
+ #
45
+ # @return [CalibrationReport::Summary]
46
+ def run
47
+ report = CalibrationReport.new
48
+
49
+ @cases.each do |tc|
50
+ tc[:runs].times do |run_idx|
51
+ result = @provider.evaluate(
52
+ state: tc[:state],
53
+ decisions: tc[:decisions]
54
+ )
55
+
56
+ tc[:decisions].each do |decision_id, decision|
57
+ answer = result[decision_id.to_s]
58
+ next unless answer
59
+
60
+ predicted, confidence = extract_prediction(answer)
61
+ outcome = check_expected(tc[:expected][decision_id], answer)
62
+ correct = predicted == outcome
63
+
64
+ report.record(
65
+ decision_id: "#{tc[:id]}.#{decision_id}",
66
+ confidence: confidence || 0.5,
67
+ predicted: predicted.to_s,
68
+ outcome: outcome.to_s,
69
+ metadata: { run: run_idx, case_id: tc[:id] }
70
+ )
71
+ end
72
+ end
73
+ end
74
+
75
+ report.summarize
76
+ end
77
+
78
+ private
79
+
80
+ def extract_prediction(answer)
81
+ case answer
82
+ when DecisionResult::ChoiceAnswer
83
+ [answer.choice, answer.confidence]
84
+ when DecisionResult::ScoreAnswer
85
+ [answer.score, answer.confidence]
86
+ when DecisionResult::NoulAnswer
87
+ [answer.noul, nil] # noul has no confidence
88
+ else
89
+ [nil, nil]
90
+ end
91
+ end
92
+
93
+ def check_expected(expected, answer)
94
+ return "pass" unless expected
95
+
96
+ if expected[:choice_is]
97
+ answer.respond_to?(:choice) && answer.choice == expected[:choice_is] ? "pass" : "fail"
98
+ elsif expected[:noul_above]
99
+ answer.respond_to?(:noul) && answer.noul >= expected[:noul_above] ? "pass" : "fail"
100
+ elsif expected[:noul_below]
101
+ answer.respond_to?(:noul) && answer.noul <= expected[:noul_below] ? "pass" : "fail"
102
+ elsif expected[:score_above]
103
+ answer.respond_to?(:score) && answer.score >= expected[:score_above] ? "pass" : "fail"
104
+ elsif expected[:confidence_above]
105
+ answer.respond_to?(:confidence) && answer.confidence && answer.confidence >= expected[:confidence_above] ? "pass" : "fail"
106
+ else
107
+ "pass"
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Decisions
5
+ # Records decision outcomes and produces a calibration report —
6
+ # reliability curves per decision id, variance across runs, and
7
+ # threshold sweep.
8
+ #
9
+ # This is the measurement loop that makes confidence thresholds
10
+ # trustworthy. Without it, thresholds are guesses.
11
+ #
12
+ # report = Ask::Decisions::CalibrationReport.new
13
+ #
14
+ # # Record a decision and its outcome.
15
+ # report.record(
16
+ # decision_id: "tool.route",
17
+ # confidence: 0.89,
18
+ # predicted: "bash",
19
+ # outcome: "bash", # what actually happened
20
+ # latency: 0.12
21
+ # )
22
+ #
23
+ # # Generate the report.
24
+ # summary = report.summarize
25
+ # summary.accuracy # => 0.92
26
+ # summary.calibration # => 0.85 (Brier-like score)
27
+ # summary.by_confidence # => [{ range: "0.9–1.0", count: 15, accuracy: 0.93 }, ...]
28
+ #
29
+ class CalibrationReport
30
+ attr_reader :records
31
+
32
+ def initialize
33
+ @records = []
34
+ end
35
+
36
+ # Record a decision and its outcome.
37
+ #
38
+ # @param decision_id [String] the decision id
39
+ # @param confidence [Float] the model's confidence
40
+ # @param predicted [String] what the model chose
41
+ # @param outcome [String] what actually happened (ground truth)
42
+ # @param latency [Float, nil] response time in seconds
43
+ # @param metadata [Hash] extra data (tool name, risk level, etc.)
44
+ def record(decision_id:, confidence:, predicted:, outcome:, latency: nil, metadata: {})
45
+ @records << {
46
+ decision_id: decision_id.to_s,
47
+ confidence: confidence.to_f,
48
+ predicted: predicted.to_s,
49
+ outcome: outcome.to_s,
50
+ latency: latency,
51
+ metadata: metadata,
52
+ timestamp: Time.now.to_f
53
+ }
54
+ end
55
+
56
+ # Generate the calibration summary.
57
+ #
58
+ # @return [Summary]
59
+ def summarize
60
+ Summary.new(@records)
61
+ end
62
+
63
+ # Reset all records.
64
+ def clear
65
+ @records.clear
66
+ end
67
+
68
+ # Summary of recorded decisions.
69
+ class Summary
70
+ attr_reader :total, :correct, :accuracy, :calibration, :by_confidence, :by_decision_id
71
+
72
+ def initialize(records)
73
+ @records = records
74
+ @total = records.size
75
+ @correct = records.count { |r| r[:predicted] == r[:outcome] }
76
+ @accuracy = @total > 0 ? @correct.to_f / @total : 0.0
77
+ @calibration = compute_calibration(records)
78
+ @by_confidence = bucket_by_confidence(records)
79
+ @by_decision_id = group_by_decision_id(records)
80
+ end
81
+
82
+ # Overall accuracy.
83
+ def accuracy_pct
84
+ ("%.1f%%" % (@accuracy * 100))
85
+ end
86
+
87
+ # Average latency.
88
+ def avg_latency
89
+ latencies = @records.filter_map { |r| r[:latency] }
90
+ return nil if latencies.empty?
91
+ latencies.sum / latencies.size
92
+ end
93
+
94
+ # Print a human-readable report.
95
+ def to_s
96
+ lines = []
97
+ lines << "=== Calibration Report ==="
98
+ lines << "Total decisions: #{@total}"
99
+ lines << "Correct: #{@correct} (#{accuracy_pct})"
100
+ lines << "Calibration (Brier): #{('%.4f' % @calibration)}" if @calibration
101
+ lines << "Avg latency: #{('%.0fms' % (avg_latency * 1000))}" if avg_latency
102
+ lines << ""
103
+ lines << "By confidence band:"
104
+ @by_confidence.each do |band|
105
+ lines << " #{band[:range]}: #{band[:count]} decisions, #{band[:accuracy_pct]} accuracy"
106
+ end
107
+ lines << ""
108
+ lines << "By decision id:"
109
+ @by_decision_id.each do |id, data|
110
+ lines << " #{id}: #{data[:count]} decisions, #{data[:accuracy_pct]} accuracy"
111
+ end
112
+ lines.join("\n")
113
+ end
114
+
115
+ private
116
+
117
+ # Brier-like calibration score: mean squared error between
118
+ # confidence and outcome (1 if correct, 0 if wrong).
119
+ def compute_calibration(records)
120
+ return nil if records.empty?
121
+ records.sum do |r|
122
+ actual = r[:predicted] == r[:outcome] ? 1.0 : 0.0
123
+ (r[:confidence] - actual) ** 2
124
+ end / records.size
125
+ end
126
+
127
+ # Bucket records by confidence ranges.
128
+ def bucket_by_confidence(records)
129
+ ranges = [
130
+ { min: 0.9, max: 1.0, label: "0.9–1.0" },
131
+ { min: 0.7, max: 0.9, label: "0.7–0.9" },
132
+ { min: 0.5, max: 0.7, label: "0.5–0.7" },
133
+ { min: 0.3, max: 0.5, label: "0.3–0.5" },
134
+ { min: 0.0, max: 0.3, label: "0.0–0.3" }
135
+ ]
136
+
137
+ ranges.filter_map do |range|
138
+ bucket = records.select { |r| r[:confidence] >= range[:min] && r[:confidence] < range[:max] }
139
+ next if bucket.empty?
140
+ correct = bucket.count { |r| r[:predicted] == r[:outcome] }
141
+ acc = correct.to_f / bucket.size
142
+ {
143
+ range: range[:label],
144
+ count: bucket.size,
145
+ accuracy: acc,
146
+ accuracy_pct: "%.1f%%" % (acc * 100),
147
+ avg_confidence: bucket.sum { |r| r[:confidence] } / bucket.size
148
+ }
149
+ end
150
+ end
151
+
152
+ # Group records by decision id.
153
+ def group_by_decision_id(records)
154
+ records.group_by { |r| r[:decision_id] }.transform_values do |group|
155
+ correct = group.count { |r| r[:predicted] == r[:outcome] }
156
+ acc = correct.to_f / group.size
157
+ {
158
+ count: group.size,
159
+ accuracy: acc,
160
+ accuracy_pct: "%.1f%%" % (acc * 100),
161
+ avg_confidence: group.sum { |r| r[:confidence] } / group.size
162
+ }
163
+ end
164
+ end
165
+ end
166
+ end
167
+ end
168
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Decisions
5
+ # Gates actions on confidence thresholds. Extends the existing
6
+ # approval system with a confidence axis: high confidence → act,
7
+ # medium → require approval, low → escalate to LLM/human.
8
+ #
9
+ # policy = Ask::Decisions::ConfidencePolicy.new
10
+ # policy.add_rule(:bash, risk: :low, act_threshold: 0.5, review_threshold: 0.3)
11
+ # policy.add_rule(:write, risk: :high, act_threshold: 0.9, review_threshold: 0.7)
12
+ #
13
+ # decision = policy.evaluate(tool: "bash", confidence: 0.6)
14
+ # decision.action # => :act
15
+ #
16
+ # decision = policy.evaluate(tool: "write", confidence: 0.8)
17
+ # decision.action # => :review (need approval)
18
+ #
19
+ class ConfidencePolicy
20
+ # Default rules by risk level. Matches the pi-jev calibration:
21
+ # read-only tools act at 0.5, destructive tools need 0.9.
22
+ DEFAULT_RULES = {
23
+ low: { act_threshold: 0.5, review_threshold: 0.3 },
24
+ medium: { act_threshold: 0.7, review_threshold: 0.5 },
25
+ high: { act_threshold: 0.9, review_threshold: 0.7 }
26
+ }.freeze
27
+
28
+ def initialize
29
+ @rules = {} # tool_name → { risk:, act_threshold:, review_threshold: }
30
+ @default_risk = :medium
31
+ end
32
+
33
+ # Add a rule for a specific tool.
34
+ #
35
+ # @param tool [String, Symbol] the tool name
36
+ # @param risk [Symbol] :low, :medium, or :high
37
+ # @param act_threshold [Float, nil] override the act threshold
38
+ # @param review_threshold [Float, nil] override the review threshold
39
+ def add_rule(tool, risk: :medium, act_threshold: nil, review_threshold: nil)
40
+ defaults = DEFAULT_RULES[risk] || DEFAULT_RULES[:medium]
41
+ @rules[tool.to_s] = {
42
+ risk: risk,
43
+ act_threshold: act_threshold || defaults[:act_threshold],
44
+ review_threshold: review_threshold || defaults[:review_threshold]
45
+ }
46
+ end
47
+
48
+ # Set the default risk level for tools without explicit rules.
49
+ def default_risk=(risk)
50
+ @default_risk = risk
51
+ end
52
+
53
+ # Evaluate whether an action should proceed based on confidence.
54
+ #
55
+ # @param tool [String] the tool name
56
+ # @param confidence [Float, nil] the decision's confidence
57
+ # @return [PolicyDecision]
58
+ def evaluate(tool:, confidence: nil)
59
+ rule = @rules[tool.to_s] || DEFAULT_RULES[@default_risk] || DEFAULT_RULES[:medium]
60
+
61
+ action = classify(confidence, rule)
62
+ PolicyDecision.new(
63
+ tool: tool,
64
+ confidence: confidence,
65
+ action: action,
66
+ risk: rule[:risk],
67
+ act_threshold: rule[:act_threshold],
68
+ review_threshold: rule[:review_threshold]
69
+ )
70
+ end
71
+
72
+ private
73
+
74
+ def classify(confidence, rule)
75
+ return :escalate if confidence.nil?
76
+
77
+ if confidence >= rule[:act_threshold]
78
+ :act
79
+ elsif confidence >= rule[:review_threshold]
80
+ :review
81
+ else
82
+ :escalate
83
+ end
84
+ end
85
+
86
+ # Decision from policy evaluation.
87
+ class PolicyDecision
88
+ attr_reader :tool, :confidence, :action, :risk, :act_threshold, :review_threshold
89
+
90
+ def initialize(tool:, confidence:, action:, risk:, act_threshold:, review_threshold:)
91
+ @tool = tool
92
+ @confidence = confidence
93
+ @action = action
94
+ @risk = risk
95
+ @act_threshold = act_threshold
96
+ @review_threshold = review_threshold
97
+ end
98
+
99
+ def act? = action == :act
100
+ def review? = action == :review
101
+ def escalate? = action == :escalate
102
+
103
+ def to_s
104
+ case action
105
+ when :act then "act on #{tool} (confidence #{fmt})"
106
+ when :review then "review #{tool} (confidence #{fmt}, need approval)"
107
+ when :escalate then "escalate #{tool} (confidence #{fmt}, below threshold)"
108
+ end
109
+ end
110
+
111
+ private
112
+
113
+ def fmt
114
+ confidence ? "%.2f" % confidence : "nil"
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Decisions
5
+ # Projects session state into a compact document for Jev. Filters out
6
+ # irrelevant fields, truncates to a token budget, and produces the
7
+ # structured state that System One models expect.
8
+ #
9
+ # Jev degrades with irrelevant detail ("context rot"), so the
10
+ # projection layer is the single most important piece of the
11
+ # integration: it determines accuracy by deciding what the model sees.
12
+ #
13
+ # state = Ask::Decisions::DecisionState.build(
14
+ # user_turn: "check the weather in seattle tomorrow",
15
+ # recent_turns: [...],
16
+ # tools: [...],
17
+ # plan: "Investigate the weather API...",
18
+ # budget: 8000
19
+ # )
20
+ #
21
+ class DecisionState
22
+ # Approximate chars per token (conservative for mixed content).
23
+ CHARS_PER_TOKEN = 4
24
+
25
+ # Build a compact state document from session components.
26
+ #
27
+ # @param user_turn [String] the latest user message
28
+ # @param recent_turns [Array<Hash>, nil] recent conversation messages
29
+ # @param tools [Array<Hash>, nil] tool roster (name + description)
30
+ # @param agent_roster [Array<Hash>, nil] available agents
31
+ # @param plan [String, nil] current plan/task description
32
+ # @param memory [Array<String>, nil] relevant memory entries
33
+ # @param todos [Array<Hash>, nil] current todo items
34
+ # @param budget [Integer] max characters for the state
35
+ # @return [Hash] the projected state
36
+ def self.build(
37
+ user_turn:,
38
+ recent_turns: nil,
39
+ tools: nil,
40
+ agent_roster: nil,
41
+ plan: nil,
42
+ memory: nil,
43
+ todos: nil,
44
+ budget: 32_000 * CHARS_PER_TOKEN
45
+ )
46
+ state = {}
47
+ remaining = budget
48
+
49
+ # Always include the user turn (highest priority).
50
+ truncated_turn = truncate(user_turn, remaining - 100)
51
+ state[:user_turn] = truncated_turn
52
+ remaining -= truncated_turn.length
53
+
54
+ # Add recent turns if there's budget.
55
+ if recent_turns && remaining > 500
56
+ recent = truncate_messages(recent_turns, remaining / 2)
57
+ state[:recent_turns] = recent
58
+ remaining -= recent.to_s.length
59
+ end
60
+
61
+ # Add tools if there's budget.
62
+ if tools && remaining > 200
63
+ tool_summary = tools.map do |t|
64
+ name = t[:name] || t["name"]
65
+ desc = t[:description] || t["description"] || ""
66
+ "#{name}: #{truncate(desc, 80)}"
67
+ end
68
+ state[:available_tools] = tool_summary
69
+ remaining -= tool_summary.to_s.length
70
+ end
71
+
72
+ # Add plan if there's budget.
73
+ if plan && remaining > 100
74
+ state[:plan] = truncate(plan, remaining / 3)
75
+ remaining -= state[:plan].length
76
+ end
77
+
78
+ # Add memory if there's budget.
79
+ if memory && remaining > 100
80
+ state[:memory] = memory.first(5).map { |m| truncate(m, 200) }
81
+ remaining -= state[:memory].to_s.length
82
+ end
83
+
84
+ # Add todos if there's budget.
85
+ if todos && remaining > 100
86
+ state[:todos] = todos.first(10).map do |t|
87
+ { task: t[:task] || t["task"], status: t[:status] || t["status"] }
88
+ end
89
+ remaining -= state[:todos].to_s.length
90
+ end
91
+
92
+ state
93
+ end
94
+
95
+ # Truncate a string to a character budget.
96
+ def self.truncate(str, limit)
97
+ return "" if str.nil?
98
+ str = str.to_s
99
+ return str if str.length <= limit
100
+ "#{str[0, limit - 20]}…[#{str.length - limit + 20} chars elided]"
101
+ end
102
+
103
+ # Truncate an array of messages to fit within a character budget,
104
+ # keeping the most recent messages first.
105
+ def self.truncate_messages(messages, budget)
106
+ return [] if messages.nil? || messages.empty?
107
+ result = []
108
+ used = 0
109
+ # Walk from most recent to oldest.
110
+ messages.reverse_each do |msg|
111
+ text = msg[:content] || msg["content"] || msg.to_s
112
+ entry = { role: msg[:role] || msg["role"], content: truncate(text, 2000) }
113
+ entry_len = entry.to_s.length
114
+ break if used + entry_len > budget
115
+ result.unshift(entry)
116
+ used += entry_len
117
+ end
118
+ result
119
+ end
120
+ end
121
+ end
122
+ end