ask-decisions 0.2.1 → 0.2.3
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/CHANGELOG.md +21 -0
- data/lib/ask/decisions/agent_adapter.rb +10 -0
- data/lib/ask/decisions/calibration_report.rb +10 -2
- data/lib/ask/decisions/compactor.rb +412 -0
- data/lib/ask/decisions/threshold_judge.rb +75 -0
- data/lib/ask/decisions/version.rb +1 -1
- data/lib/ask-decisions.rb +2 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e94c483511119929cad9bbdf2d0925ca498e4b4f98f1119c05ac3c26a239edfc
|
|
4
|
+
data.tar.gz: e09632e3fbaa5f2dca5ffc8692afe586d1818b985db8afaa600f3498e614ab9f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a55fb6e583a983afd05acde0048d203d2e81c3098ef3cfa9d278214a3e4cd2de47ba2bd0f78fcb572f078da1c246fc4dc2aa22f2d23380cd79dc7d5c5fdbfe70
|
|
7
|
+
data.tar.gz: 57e4a24f195eb27e518e88e7340850e27a90499c2a22ac5a3069cca63d787fee3623f350ccf893fb0794f4eb13667656fd82abff369b9ce71c0c62b30f1a0490
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,27 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
6
6
|
|
|
7
|
+
## [0.2.3] - 2026-09-18
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **`Ask::Decisions::ThresholdJudge`** — binary accept/reject judge for
|
|
12
|
+
simple gate decisions. Runs a single Noul question and thresholds the
|
|
13
|
+
result. Use this when you need "does this pass?" without the full
|
|
14
|
+
rubric of `QualityJudge`. Returns a `ThresholdVerdict` with `passed?`,
|
|
15
|
+
`noul`, and `confidence`.
|
|
16
|
+
|
|
17
|
+
## [0.2.2] - 2026-09-18
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
|
|
21
|
+
- **A confidence of exactly 1.0 lands in the top band of
|
|
22
|
+
`CalibrationReport#summarize`.** The bands were half-open at both ends
|
|
23
|
+
(`min <= confidence < max`), so the surest answer a decider can give — 1.0,
|
|
24
|
+
and in practice the most common one — belonged to no band at all. A report
|
|
25
|
+
written for setting a threshold was quietly dropping the decisions that most
|
|
26
|
+
justify one. The top band is now inclusive of 1.0.
|
|
27
|
+
|
|
7
28
|
## [0.2.1] - 2026-09-17
|
|
8
29
|
|
|
9
30
|
### Added
|
|
@@ -44,6 +44,16 @@ module Ask
|
|
|
44
44
|
@tool_repairer = ToolRepairer.new(@provider)
|
|
45
45
|
end
|
|
46
46
|
|
|
47
|
+
# Build a decision-based compactor for the current provider.
|
|
48
|
+
#
|
|
49
|
+
# compactor = adapter.build_compactor(preserve_recent: 6)
|
|
50
|
+
# result = compactor.compact(session.messages)
|
|
51
|
+
# result.messages # => pruned conversation
|
|
52
|
+
#
|
|
53
|
+
def build_compactor(**opts)
|
|
54
|
+
Ask::Decisions::Compactor.new(@provider, **opts)
|
|
55
|
+
end
|
|
56
|
+
|
|
47
57
|
# Generate before_tool hooks for ask-agent.
|
|
48
58
|
# Returns an array of callables that match the Hooks interface.
|
|
49
59
|
def before_tool_hooks
|
|
@@ -125,9 +125,14 @@ module Ask
|
|
|
125
125
|
end
|
|
126
126
|
|
|
127
127
|
# Bucket records by confidence ranges.
|
|
128
|
+
#
|
|
129
|
+
# The top band includes 1.0: a confidence of exactly 1.0 is the most
|
|
130
|
+
# common answer a calibrated decider gives, and with the upper bound
|
|
131
|
+
# exclusive it fell out of every band — so the surest decisions were
|
|
132
|
+
# the ones the report never showed.
|
|
128
133
|
def bucket_by_confidence(records)
|
|
129
134
|
ranges = [
|
|
130
|
-
{ min: 0.9, max: 1.0, label: "0.9–1.0" },
|
|
135
|
+
{ min: 0.9, max: 1.0, label: "0.9–1.0", top: true },
|
|
131
136
|
{ min: 0.7, max: 0.9, label: "0.7–0.9" },
|
|
132
137
|
{ min: 0.5, max: 0.7, label: "0.5–0.7" },
|
|
133
138
|
{ min: 0.3, max: 0.5, label: "0.3–0.5" },
|
|
@@ -135,7 +140,10 @@ module Ask
|
|
|
135
140
|
]
|
|
136
141
|
|
|
137
142
|
ranges.filter_map do |range|
|
|
138
|
-
bucket = records.select
|
|
143
|
+
bucket = records.select do |r|
|
|
144
|
+
r[:confidence] >= range[:min] &&
|
|
145
|
+
(range[:top] ? r[:confidence] <= range[:max] : r[:confidence] < range[:max])
|
|
146
|
+
end
|
|
139
147
|
next if bucket.empty?
|
|
140
148
|
correct = bucket.count { |r| r[:predicted] == r[:outcome] }
|
|
141
149
|
acc = correct.to_f / bucket.size
|
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Decisions
|
|
5
|
+
# Decision-based conversation compactor. Replaces lossy summarization with
|
|
6
|
+
# Jev-scored pruning: every tool call and result is evaluated for relevance,
|
|
7
|
+
# and the irrelevant ones are dropped or truncated — everything kept stays
|
|
8
|
+
# verbatim.
|
|
9
|
+
#
|
|
10
|
+
# This is the Ruby equivalent of the fast-jev-compaction approach: rather
|
|
11
|
+
# than asking an LLM to summarize old turns (which loses file paths, exact
|
|
12
|
+
# errors, constraints, and command details), Jev scores each tool call and
|
|
13
|
+
# result independently, and the compactor applies three-tier decisions.
|
|
14
|
+
#
|
|
15
|
+
# compactor = Ask::Decisions::Compactor.new(provider)
|
|
16
|
+
# result = compactor.compact(messages)
|
|
17
|
+
# result.messages # => pruned message list
|
|
18
|
+
# result.stats # => { kept: 12, dropped: 5, truncated: 3, ... }
|
|
19
|
+
# result.reduction_ratio # => 0.29 (29% of tool content removed)
|
|
20
|
+
#
|
|
21
|
+
# Message format (hashes):
|
|
22
|
+
#
|
|
23
|
+
# { role: "user", content: "Fix the failing test" }
|
|
24
|
+
# { role: "assistant", content: "",
|
|
25
|
+
# tool_calls: [{ id: "call_1", name: "Read", input: { path: "src/a.rb" } }] }
|
|
26
|
+
# { role: "tool", content: "file contents...",
|
|
27
|
+
# tool_call_id: "call_1" }
|
|
28
|
+
#
|
|
29
|
+
# The first message is always kept. The most recent +preserve_recent+
|
|
30
|
+
# messages are never touched. Everything else is a candidate for pruning.
|
|
31
|
+
#
|
|
32
|
+
class Compactor
|
|
33
|
+
# Approximate chars per token for budget calculations.
|
|
34
|
+
CHARS_PER_TOKEN = 4
|
|
35
|
+
|
|
36
|
+
# Jev Noul questions asked per tool call.
|
|
37
|
+
CALL_QUESTION = "Should this tool call be kept — does knowing it was " \
|
|
38
|
+
"made still matter for the current conversation?"
|
|
39
|
+
RESULT_QUESTION = "Should this tool result be kept verbatim — are its " \
|
|
40
|
+
"contents still needed and would re-running the tool " \
|
|
41
|
+
"not suffice?"
|
|
42
|
+
|
|
43
|
+
# @param provider [Ask::DecisionProvider] the Jev/decision provider
|
|
44
|
+
# @param keep_threshold [Float] minimum Noul probability to keep (0.0–1.0)
|
|
45
|
+
# @param preserve_recent [Integer] newest messages never touched
|
|
46
|
+
# @param max_state_tokens [Integer] token ceiling for state sent to Jev
|
|
47
|
+
# @param max_request_tokens [Integer] token ceiling per batch request
|
|
48
|
+
# @param truncate_head_chars [Integer] chars retained from dropped results
|
|
49
|
+
# @param goal [String, nil] ongoing task description for Jev context
|
|
50
|
+
def initialize(provider,
|
|
51
|
+
keep_threshold: 0.5,
|
|
52
|
+
preserve_recent: 4,
|
|
53
|
+
max_state_tokens: 25_000,
|
|
54
|
+
max_request_tokens: 30_000,
|
|
55
|
+
truncate_head_chars: 300,
|
|
56
|
+
goal: nil)
|
|
57
|
+
@provider = provider
|
|
58
|
+
@keep_threshold = keep_threshold
|
|
59
|
+
@preserve_recent = preserve_recent
|
|
60
|
+
@max_state_tokens = max_state_tokens
|
|
61
|
+
@max_request_tokens = max_request_tokens
|
|
62
|
+
@truncate_head_chars = truncate_head_chars
|
|
63
|
+
@goal = goal
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Compact a conversation by scoring every tool call and result with Jev,
|
|
67
|
+
# then dropping or truncating the irrelevant ones.
|
|
68
|
+
#
|
|
69
|
+
# @param messages [Array<Hash>] conversation messages
|
|
70
|
+
# @return [Result] compacted messages and stats
|
|
71
|
+
def compact(messages)
|
|
72
|
+
messages = normalize(messages)
|
|
73
|
+
return Result.new(messages.dup, original_count: messages.size) if messages.size < 2
|
|
74
|
+
|
|
75
|
+
pairs = build_pairs(messages)
|
|
76
|
+
pinned = compute_pinned(messages)
|
|
77
|
+
|
|
78
|
+
return Result.new(messages.dup, original_count: messages.size) if pairs.empty?
|
|
79
|
+
|
|
80
|
+
state = build_state(messages, pairs)
|
|
81
|
+
questions = build_questions(pairs, pinned)
|
|
82
|
+
batch = batch_and_execute(state, questions)
|
|
83
|
+
decisions = extract_decisions(batch, pairs)
|
|
84
|
+
pruned = apply_decisions(messages, decisions, pinned)
|
|
85
|
+
stats = compute_stats(messages, pruned, decisions)
|
|
86
|
+
|
|
87
|
+
Result.new(pruned, stats: stats, original_count: messages.size)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
# ── Normalization ────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
def normalize(messages)
|
|
95
|
+
Array(messages).map { |m| m.is_a?(Hash) ? m : hashify(m) }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def hashify(msg)
|
|
99
|
+
{
|
|
100
|
+
role: msg.respond_to?(:role) ? msg.role.to_s : "unknown",
|
|
101
|
+
content: msg.respond_to?(:content) ? msg.content.to_s : msg.to_s,
|
|
102
|
+
tool_calls: extract_tool_calls_from_object(msg),
|
|
103
|
+
tool_call_id: msg.respond_to?(:tool_call_id) ? msg.tool_call_id : nil
|
|
104
|
+
}.compact
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def extract_tool_calls_from_object(msg)
|
|
108
|
+
return nil unless msg.respond_to?(:tool_calls) && msg.tool_calls
|
|
109
|
+
Array(msg.tool_calls).map do |tc|
|
|
110
|
+
{
|
|
111
|
+
id: tc.respond_to?(:id) ? tc.id : tc[:id],
|
|
112
|
+
name: tc.respond_to?(:name) ? tc.name : tc[:name],
|
|
113
|
+
input: tc.respond_to?(:arguments) ? tc.arguments : (tc[:input] || tc[:arguments])
|
|
114
|
+
}
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# ── Pairing ──────────────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
# Group messages into tool_call / tool_result pairs by tool_call_id.
|
|
121
|
+
# Returns an array of { call_msg:, result_msg:, call: } hashes.
|
|
122
|
+
def build_pairs(messages)
|
|
123
|
+
call_index = {}
|
|
124
|
+
messages.each_with_index do |msg, idx|
|
|
125
|
+
Array(msg[:tool_calls]).each do |tc|
|
|
126
|
+
call_index[tc[:id]] = { call_msg_idx: idx, call: tc }
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
pairs = []
|
|
131
|
+
messages.each_with_index do |msg, idx|
|
|
132
|
+
tcid = msg[:tool_call_id]
|
|
133
|
+
next unless tcid && call_index[tcid]
|
|
134
|
+
|
|
135
|
+
entry = call_index[tcid]
|
|
136
|
+
pairs << {
|
|
137
|
+
call_msg_idx: entry[:call_msg_idx],
|
|
138
|
+
result_msg_idx: idx,
|
|
139
|
+
call: entry[:call],
|
|
140
|
+
call_id: tcid
|
|
141
|
+
}
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
pairs.sort_by { |p| p[:call_msg_idx] }
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# First message and the most recent +preserve_recent+ messages are pinned.
|
|
148
|
+
def compute_pinned(messages)
|
|
149
|
+
pinned = Set.new([0])
|
|
150
|
+
start = [messages.size - @preserve_recent, 1].max
|
|
151
|
+
(start...messages.size).each { |i| pinned.add(i) }
|
|
152
|
+
pinned
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# ── State ────────────────────────────────────────────────────────
|
|
156
|
+
|
|
157
|
+
# Build the state Jev sees: full conversation with tool results replaced
|
|
158
|
+
# by short placeholder notes. Tool inputs are included, text is included,
|
|
159
|
+
# nothing is summarized.
|
|
160
|
+
def build_state(messages, pairs)
|
|
161
|
+
result_index = {}
|
|
162
|
+
pairs.each { |p| result_index[p[:result_msg_idx]] = p }
|
|
163
|
+
|
|
164
|
+
lines = messages.each_with_index.map do |msg, idx|
|
|
165
|
+
role = msg[:role] || "unknown"
|
|
166
|
+
content = msg[:content].to_s
|
|
167
|
+
|
|
168
|
+
if result_index[idx]
|
|
169
|
+
result_index[idx][:result_preview] = content
|
|
170
|
+
chars = content.length
|
|
171
|
+
"[tool result: ok, #{chars} chars (omitted)]"
|
|
172
|
+
elsif msg[:tool_calls] && !msg[:tool_calls].empty?
|
|
173
|
+
tc_strs = msg[:tool_calls].map do |tc|
|
|
174
|
+
input_str = format_input(tc[:input])
|
|
175
|
+
"#{tc[:name]}(#{input_str})"
|
|
176
|
+
end
|
|
177
|
+
tc_strs.empty? ? content : "#{content}\n#{tc_strs.join("\n")}"
|
|
178
|
+
else
|
|
179
|
+
content
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
goal_section = @goal ? "\n\nGoal: #{@goal}" : ""
|
|
184
|
+
{ conversation: lines, goal: @goal }.compact
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def format_input(input)
|
|
188
|
+
return "" unless input
|
|
189
|
+
str = input.is_a?(String) ? input : JSON.generate(input)
|
|
190
|
+
str.length > 100 ? "#{str[0, 100]}..." : str
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# ── Questions ────────────────────────────────────────────────────
|
|
194
|
+
|
|
195
|
+
def build_questions(pairs, pinned)
|
|
196
|
+
questions = {}
|
|
197
|
+
pairs.each do |pair|
|
|
198
|
+
next if pinned.include?(pair[:call_msg_idx]) && pinned.include?(pair[:result_msg_idx])
|
|
199
|
+
|
|
200
|
+
call_id = pair[:call_id]
|
|
201
|
+
tc = pair[:call]
|
|
202
|
+
input_str = format_input(tc[:input])
|
|
203
|
+
|
|
204
|
+
questions["keep_call_#{call_id}"] = Ask::Decision::Noul.new(
|
|
205
|
+
instructions: "#{CALL_QUESTION}\n\nTool: #{tc[:name]}\nInput: #{input_str}"
|
|
206
|
+
)
|
|
207
|
+
questions["keep_result_#{call_id}"] = Ask::Decision::Noul.new(
|
|
208
|
+
instructions: "#{RESULT_QUESTION}\n\nTool: #{tc[:name]}\nInput: #{input_str}"
|
|
209
|
+
)
|
|
210
|
+
end
|
|
211
|
+
questions
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# ── Batching ─────────────────────────────────────────────────────
|
|
215
|
+
|
|
216
|
+
# Split questions into batches that fit the request token budget,
|
|
217
|
+
# send each batch concurrently (sequentially in Ruby, but the same
|
|
218
|
+
# pattern as fast-jev-compaction), and merge results.
|
|
219
|
+
def batch_and_execute(state, questions)
|
|
220
|
+
return empty_batch if questions.empty?
|
|
221
|
+
|
|
222
|
+
batches = partition_batches(state, questions)
|
|
223
|
+
return execute_batch(state, questions) if batches.size <= 1
|
|
224
|
+
|
|
225
|
+
results = batches.map { |batch| execute_batch(state, batch) }
|
|
226
|
+
merge_batches(results)
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def partition_batches(state, questions)
|
|
230
|
+
state_tokens = estimate_tokens(JSON.generate(state))
|
|
231
|
+
pairs = questions.to_a
|
|
232
|
+
batches = []
|
|
233
|
+
current = {}
|
|
234
|
+
current_tokens = state_tokens
|
|
235
|
+
|
|
236
|
+
pairs.each do |id, decision|
|
|
237
|
+
q_tokens = estimate_tokens(JSON.generate(decision.to_h))
|
|
238
|
+
if current_tokens + q_tokens > @max_request_tokens && current.any?
|
|
239
|
+
batches << current
|
|
240
|
+
current = {}
|
|
241
|
+
current_tokens = state_tokens
|
|
242
|
+
end
|
|
243
|
+
current[id] = decision
|
|
244
|
+
current_tokens += q_tokens
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
batches << current if current.any?
|
|
248
|
+
batches
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def execute_batch(state, questions)
|
|
252
|
+
@provider.evaluate(
|
|
253
|
+
state: JSON.generate(state),
|
|
254
|
+
decisions: questions
|
|
255
|
+
)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def empty_batch
|
|
259
|
+
Ask::DecisionResult::Batch.new(answers: {})
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def merge_batches(batches)
|
|
263
|
+
merged = {}
|
|
264
|
+
batches.each do |batch|
|
|
265
|
+
batch.answers.each { |k, v| merged[k] = v }
|
|
266
|
+
end
|
|
267
|
+
Ask::DecisionResult::Batch.new(answers: merged)
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# ── Decisions ────────────────────────────────────────────────────
|
|
271
|
+
|
|
272
|
+
def extract_decisions(batch, pairs)
|
|
273
|
+
decisions = {}
|
|
274
|
+
pairs.each do |pair|
|
|
275
|
+
call_id = pair[:call_id]
|
|
276
|
+
keep_call_answer = batch["keep_call_#{call_id}"]
|
|
277
|
+
keep_result_answer = batch["keep_result_#{call_id}"]
|
|
278
|
+
|
|
279
|
+
keep_call = keep_call_answer&.noul || 0.0
|
|
280
|
+
keep_result = keep_result_answer&.noul || 0.0
|
|
281
|
+
|
|
282
|
+
decisions[call_id] = {
|
|
283
|
+
keep_call: keep_call,
|
|
284
|
+
keep_result: keep_result,
|
|
285
|
+
action: classify_action(keep_call, keep_result)
|
|
286
|
+
}
|
|
287
|
+
end
|
|
288
|
+
decisions
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# Three-tier decision classification.
|
|
292
|
+
def classify_action(keep_call, keep_result)
|
|
293
|
+
if keep_result >= @keep_threshold
|
|
294
|
+
:keep
|
|
295
|
+
elsif keep_call >= @keep_threshold
|
|
296
|
+
:truncate
|
|
297
|
+
else
|
|
298
|
+
:drop
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# ── Apply ────────────────────────────────────────────────────────
|
|
303
|
+
|
|
304
|
+
def apply_decisions(messages, decisions, pinned)
|
|
305
|
+
pairs = build_pairs(messages)
|
|
306
|
+
remove_indices = Set.new
|
|
307
|
+
truncate_indices = {}
|
|
308
|
+
|
|
309
|
+
pairs.each do |pair|
|
|
310
|
+
decision = decisions[pair[:call_id]]
|
|
311
|
+
next unless decision
|
|
312
|
+
|
|
313
|
+
case decision[:action]
|
|
314
|
+
when :drop
|
|
315
|
+
remove_indices.add(pair[:call_msg_idx])
|
|
316
|
+
remove_indices.add(pair[:result_msg_idx])
|
|
317
|
+
when :truncate
|
|
318
|
+
truncate_indices[pair[:result_msg_idx]] = pair[:call]
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
messages.each_with_index.filter_map do |msg, idx|
|
|
323
|
+
next if remove_indices.include?(idx)
|
|
324
|
+
|
|
325
|
+
if truncate_indices[idx]
|
|
326
|
+
truncate_message(msg, truncate_indices[idx])
|
|
327
|
+
else
|
|
328
|
+
msg
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def truncate_message(msg, call)
|
|
334
|
+
original = msg[:content].to_s
|
|
335
|
+
return msg if original.length <= @truncate_head_chars
|
|
336
|
+
|
|
337
|
+
head = original[0, @truncate_head_chars]
|
|
338
|
+
truncated_content = "#{head}\n...[result truncated: #{original.length - @truncate_head_chars} chars omitted — #{call[:name]} result was kept as call-only]"
|
|
339
|
+
msg.merge(content: truncated_content)
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
# ── Stats ────────────────────────────────────────────────────────
|
|
343
|
+
|
|
344
|
+
def compute_stats(original, pruned, decisions)
|
|
345
|
+
action_counts = decisions.values.tally { |d| d[:action] }
|
|
346
|
+
original_chars = original.sum { |m| m[:content].to_s.length }
|
|
347
|
+
pruned_chars = pruned.sum { |m| m[:content].to_s.length }
|
|
348
|
+
|
|
349
|
+
{
|
|
350
|
+
messages_before: original.size,
|
|
351
|
+
messages_after: pruned.size,
|
|
352
|
+
kept: action_counts[:keep] || 0,
|
|
353
|
+
truncated: action_counts[:truncate] || 0,
|
|
354
|
+
dropped: action_counts[:drop] || 0,
|
|
355
|
+
tool_pairs_evaluated: decisions.size,
|
|
356
|
+
chars_before: original_chars,
|
|
357
|
+
chars_after: pruned_chars,
|
|
358
|
+
reduction_ratio: original_chars > 0 ? (1.0 - pruned_chars.to_f / original_chars) : 0.0
|
|
359
|
+
}
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
# ── Token estimation ─────────────────────────────────────────────
|
|
363
|
+
|
|
364
|
+
def estimate_tokens(text)
|
|
365
|
+
str = text.to_s
|
|
366
|
+
letters = str.count("a-zA-Z")
|
|
367
|
+
digits = str.count("0-9")
|
|
368
|
+
others = str.length - letters - digits
|
|
369
|
+
(letters / 6.0 + digits / 2.0 + others).ceil
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
# ── Batch result ─────────────────────────────────────────────────
|
|
373
|
+
|
|
374
|
+
def extract_call_id(answer_id, prefix)
|
|
375
|
+
answer_id.to_s.sub(/\A#{prefix}/, "")
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
# ── Result ───────────────────────────────────────────────────────
|
|
379
|
+
|
|
380
|
+
# Holds the compacted messages and compaction statistics.
|
|
381
|
+
class Result
|
|
382
|
+
attr_reader :messages, :stats, :original_count
|
|
383
|
+
|
|
384
|
+
def initialize(messages, stats: {}, original_count: nil)
|
|
385
|
+
@messages = messages
|
|
386
|
+
@stats = stats
|
|
387
|
+
@original_count = original_count || messages.size
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
# Fraction of tool content removed (0.0 = nothing removed, 1.0 = everything).
|
|
391
|
+
def reduction_ratio
|
|
392
|
+
stats[:reduction_ratio] || 0.0
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
# Whether any tool calls were dropped or truncated.
|
|
396
|
+
def compacted?
|
|
397
|
+
(stats[:dropped] || 0) > 0 || (stats[:truncated] || 0) > 0
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
def to_s
|
|
401
|
+
if compacted?
|
|
402
|
+
"compacted #{stats[:messages_before]}→#{stats[:messages_after]} messages " \
|
|
403
|
+
"(#{stats[:dropped]} dropped, #{stats[:truncated]} truncated, " \
|
|
404
|
+
"#{(reduction_ratio * 100).round(1)}% reduction)"
|
|
405
|
+
else
|
|
406
|
+
"no compaction needed (#{original_count} messages)"
|
|
407
|
+
end
|
|
408
|
+
end
|
|
409
|
+
end
|
|
410
|
+
end
|
|
411
|
+
end
|
|
412
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Decisions
|
|
5
|
+
# Binary accept/reject judge — simpler than QualityJudge when you
|
|
6
|
+
# just need "does this pass?" without a full rubric. Runs a single
|
|
7
|
+
# Noul question and thresholds the result.
|
|
8
|
+
#
|
|
9
|
+
# judge = Ask::Decisions::ThresholdJudge.new(provider)
|
|
10
|
+
# verdict = judge.evaluate(
|
|
11
|
+
# subject: "The code changes look correct and have tests.",
|
|
12
|
+
# question: "Is this code change safe to merge?"
|
|
13
|
+
# )
|
|
14
|
+
# verdict.passed? # => true
|
|
15
|
+
# verdict.confidence # => 0.92
|
|
16
|
+
#
|
|
17
|
+
# Use QualityJudge when you need multi-dimensional scoring.
|
|
18
|
+
# Use ThresholdJudge when you need a fast binary gate.
|
|
19
|
+
#
|
|
20
|
+
class ThresholdJudge
|
|
21
|
+
# @param provider [Ask::DecisionProvider]
|
|
22
|
+
def initialize(provider)
|
|
23
|
+
@provider = provider
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Evaluate a subject against a yes/no question.
|
|
27
|
+
#
|
|
28
|
+
# @param subject [String] the content to evaluate
|
|
29
|
+
# @param question [String] the yes/no question
|
|
30
|
+
# @param threshold [Float] minimum noul value to pass (default 0.7)
|
|
31
|
+
# @return [ThresholdVerdict]
|
|
32
|
+
def evaluate(subject:, question:, threshold: 0.7)
|
|
33
|
+
decisions = {
|
|
34
|
+
"verdict" => Ask::Decision::Noul.new(
|
|
35
|
+
instructions: question
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
result = @provider.evaluate(state: subject, decisions: decisions)
|
|
40
|
+
ThresholdVerdict.new(result, threshold)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Verdict from threshold evaluation.
|
|
44
|
+
class ThresholdVerdict
|
|
45
|
+
attr_reader :result, :threshold
|
|
46
|
+
|
|
47
|
+
def initialize(result, threshold)
|
|
48
|
+
@result = result
|
|
49
|
+
@threshold = threshold
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# The raw noul value (0–1, where 1 = strong yes).
|
|
53
|
+
def noul
|
|
54
|
+
answer = @result["verdict"]
|
|
55
|
+
answer&.noul || 0.0
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Did the subject pass the threshold?
|
|
59
|
+
def passed?
|
|
60
|
+
noul >= @threshold
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Confidence: distance from 0.5 (0 = uncertain, 0.5 = certain).
|
|
64
|
+
def confidence
|
|
65
|
+
(noul - 0.5).abs
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def to_s
|
|
69
|
+
status = passed? ? "PASS" : "FAIL"
|
|
70
|
+
"#{status} (noul: #{('%.2f' % noul)}, confidence: #{('%.2f' % confidence)})"
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
data/lib/ask-decisions.rb
CHANGED
|
@@ -21,12 +21,14 @@ require_relative "ask/decisions/failure_classifier"
|
|
|
21
21
|
require_relative "ask/decisions/loop_detector"
|
|
22
22
|
require_relative "ask/decisions/confidence_policy"
|
|
23
23
|
require_relative "ask/decisions/quality_judge"
|
|
24
|
+
require_relative "ask/decisions/threshold_judge"
|
|
24
25
|
require_relative "ask/decisions/reflection_judge"
|
|
25
26
|
require_relative "ask/decisions/tool_repairer"
|
|
26
27
|
require_relative "ask/decisions/reranker"
|
|
27
28
|
require_relative "ask/decisions/structured_state_loop"
|
|
28
29
|
require_relative "ask/decisions/calibration_report"
|
|
29
30
|
require_relative "ask/decisions/calibration_harness"
|
|
31
|
+
require_relative "ask/decisions/compactor"
|
|
30
32
|
|
|
31
33
|
# The Decide tool requires ask-tools (Ask::Tool base class). Load conditionally
|
|
32
34
|
# so the core gem works without a tools dependency.
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ask-decisions
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.2.
|
|
4
|
+
version: 0.2.3
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Kaka Ruto
|
|
@@ -154,6 +154,7 @@ files:
|
|
|
154
154
|
- lib/ask/decisions/cache.rb
|
|
155
155
|
- lib/ask/decisions/calibration_harness.rb
|
|
156
156
|
- lib/ask/decisions/calibration_report.rb
|
|
157
|
+
- lib/ask/decisions/compactor.rb
|
|
157
158
|
- lib/ask/decisions/confidence_policy.rb
|
|
158
159
|
- lib/ask/decisions/decision_state.rb
|
|
159
160
|
- lib/ask/decisions/failure_classifier.rb
|
|
@@ -168,6 +169,7 @@ files:
|
|
|
168
169
|
- lib/ask/decisions/reranker.rb
|
|
169
170
|
- lib/ask/decisions/static.rb
|
|
170
171
|
- lib/ask/decisions/structured_state_loop.rb
|
|
172
|
+
- lib/ask/decisions/threshold_judge.rb
|
|
171
173
|
- lib/ask/decisions/tool_repairer.rb
|
|
172
174
|
- lib/ask/decisions/triage.rb
|
|
173
175
|
- lib/ask/decisions/typesafe.rb
|