ask-eval 0.3.0 → 0.4.0
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 +19 -0
- data/lib/ask/eval/dataset.rb +122 -0
- data/lib/ask/eval/experiment.rb +129 -0
- data/lib/ask/eval/recorder.rb +1 -0
- data/lib/ask/eval/version.rb +1 -1
- data/lib/ask/eval.rb +2 -0
- metadata +17 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d396e80eace74425011173335621532f318eb2921600aa8cfcdf1e79480f4df6
|
|
4
|
+
data.tar.gz: afc9c5a9873ba2b97766cd873211d4e2e94334c9adc8a39b372f4981a84e0fb1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 88ca899713aecf0c9eec29f6470a7b0856e648f9cf54337d9252364c4ae01cd3df8f1040f5e534db7f2635e3ab8de365957858b01a11fd81d021acb81b82dde3
|
|
7
|
+
data.tar.gz: e05a44bd73f3f0236349cead4fa6f87bfe035a772f755b9ea379c3f165b01e78bb25f0cc28acbea443dc33d458031b62586bdeb61af7b35f14c897fa147998f6
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,22 @@
|
|
|
1
|
+
## [0.4.0] — 2026-08-07
|
|
2
|
+
|
|
3
|
+
### Added
|
|
4
|
+
|
|
5
|
+
- **Datasets & experiment runs — the A/B-testing loop for agents.**
|
|
6
|
+
- `Ask::Eval::Dataset` — a pinned set of fixed inputs (like fixtures, but
|
|
7
|
+
the items are prompts/tasks fed to a live agent). `add` items with
|
|
8
|
+
optional `expected` output, `context`, `tags`, and `metadata`;
|
|
9
|
+
`Dataset.save` / `Dataset.load` persist to JSON files.
|
|
10
|
+
- `Ask::Eval::Experiment` — runs a dataset against a single variant: a
|
|
11
|
+
`runner:` callable turns each input into the agent's output, an
|
|
12
|
+
optional `scorer:` callable scores it (0..1), and per-item results
|
|
13
|
+
record output, score, error, and duration. Runner errors are captured
|
|
14
|
+
per item and the run continues.
|
|
15
|
+
- `Experiment#summary` (total/passed/failed/avg_score/duration) and
|
|
16
|
+
`Experiment#compare(other)` — side-by-side per-item deltas plus an
|
|
17
|
+
aggregate verdict ("a" / "b" / "tie"), so changing a prompt or model
|
|
18
|
+
and re-running the same dataset shows the improvement.
|
|
19
|
+
|
|
1
20
|
## [0.3.0] — 2026-08-03
|
|
2
21
|
|
|
3
22
|
### Added
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Ask
|
|
6
|
+
module Eval
|
|
7
|
+
# A pinned set of fixed inputs for repeatable agent evaluation.
|
|
8
|
+
#
|
|
9
|
+
# Like Rails fixtures, a dataset gives you a deterministic baseline —
|
|
10
|
+
# but the items are *questions* (prompts/tasks fed to a live agent),
|
|
11
|
+
# not database records, and the point is running the same items against
|
|
12
|
+
# multiple variants (a changed prompt, a different model) and comparing
|
|
13
|
+
# the results.
|
|
14
|
+
#
|
|
15
|
+
# @example
|
|
16
|
+
# dataset = Ask::Eval::Dataset.new("support-cases")
|
|
17
|
+
# dataset.add(input: "The API returns 401 on stale tokens — how do I fix my auth flow?", tags: ["auth"])
|
|
18
|
+
# dataset.add(input: "Summarize this thread and suggest next steps", expected: "A summary plus actions")
|
|
19
|
+
# dataset.save("support-cases.json")
|
|
20
|
+
#
|
|
21
|
+
# reloaded = Ask::Eval::Dataset.load("support-cases.json")
|
|
22
|
+
class Dataset
|
|
23
|
+
# A single dataset item: the input fed to the agent, optional expected
|
|
24
|
+
# output, optional context, and tags/metadata for filtering.
|
|
25
|
+
Item = Data.define(:id, :input, :expected, :context, :tags, :metadata) do
|
|
26
|
+
def to_h = { id: id, input: input, expected: expected, context: context, tags: tags, metadata: metadata }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @param name [String] dataset name (used in reports)
|
|
30
|
+
# @param items [Array<Hash>] initial items with symbol or string keys
|
|
31
|
+
# ({input:, expected:, context:, tags:, metadata:})
|
|
32
|
+
def initialize(name, items: [])
|
|
33
|
+
@name = name.to_s
|
|
34
|
+
@items = []
|
|
35
|
+
items.each { |item| add(**symbolize(item)) }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @return [String] the dataset name
|
|
39
|
+
attr_reader :name
|
|
40
|
+
|
|
41
|
+
# Add an item.
|
|
42
|
+
#
|
|
43
|
+
# @param input [String] the prompt/task fed to the agent (required)
|
|
44
|
+
# @param expected [String, nil] expected/reference output
|
|
45
|
+
# @param context [String, nil] source context
|
|
46
|
+
# @param tags [Array<String>] labels for filtering
|
|
47
|
+
# @param metadata [Hash] free-form
|
|
48
|
+
# @return [Item]
|
|
49
|
+
# @raise [ArgumentError] on empty input
|
|
50
|
+
def add(input:, id: nil, expected: nil, context: nil, tags: [], metadata: {})
|
|
51
|
+
raise ArgumentError, "input is required" if input.to_s.strip.empty?
|
|
52
|
+
|
|
53
|
+
item = Item.new(
|
|
54
|
+
id: id || "item_#{@items.size + 1}",
|
|
55
|
+
input: input.to_s,
|
|
56
|
+
expected: expected,
|
|
57
|
+
context: context,
|
|
58
|
+
tags: Array(tags),
|
|
59
|
+
metadata: metadata
|
|
60
|
+
)
|
|
61
|
+
@items << item
|
|
62
|
+
item
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# @return [Array<Item>] all items, in order
|
|
66
|
+
def items = @items.dup
|
|
67
|
+
|
|
68
|
+
# @return [Integer] number of items
|
|
69
|
+
def size = @items.size
|
|
70
|
+
|
|
71
|
+
def each(&block) = @items.each(&block)
|
|
72
|
+
|
|
73
|
+
# Create an experiment over this dataset (see {Experiment}).
|
|
74
|
+
#
|
|
75
|
+
# @param runner [Proc] called with the item's input; returns the
|
|
76
|
+
# agent's output string
|
|
77
|
+
# @param scorer [Proc, nil] called with (input:, output:, expected:);
|
|
78
|
+
# returns a score (0..1)
|
|
79
|
+
# @return [Ask::Eval::Experiment]
|
|
80
|
+
def experiment(runner:, scorer: nil)
|
|
81
|
+
Experiment.new(self, runner: runner, scorer: scorer)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# @return [Hash] serialized form (for {Dataset.save})
|
|
85
|
+
def to_h
|
|
86
|
+
{ name: @name, items: @items.map(&:to_h) }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Write the dataset to a JSON file.
|
|
90
|
+
#
|
|
91
|
+
# @param path [String]
|
|
92
|
+
# @return [void]
|
|
93
|
+
def save(path)
|
|
94
|
+
File.write(path, JSON.pretty_generate(to_h))
|
|
95
|
+
nil
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Load a dataset from a JSON file written by {Dataset.save} (or any
|
|
99
|
+
# compatible {name:, items:} shape).
|
|
100
|
+
#
|
|
101
|
+
# @param path [String]
|
|
102
|
+
# @return [Ask::Eval::Dataset]
|
|
103
|
+
def self.load(path)
|
|
104
|
+
data = JSON.parse(File.read(path))
|
|
105
|
+
new(data["name"] || File.basename(path, ".json"), items: data["items"] || [])
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
private
|
|
109
|
+
|
|
110
|
+
def symbolize(obj)
|
|
111
|
+
case obj
|
|
112
|
+
when Hash
|
|
113
|
+
obj.each_with_object({}) { |(k, v), h| h[k.to_sym] = symbolize(v) }
|
|
114
|
+
when Array
|
|
115
|
+
obj.map { |e| symbolize(e) }
|
|
116
|
+
else
|
|
117
|
+
obj
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Eval
|
|
5
|
+
# Runs a {Dataset} against a single variant and compares runs.
|
|
6
|
+
#
|
|
7
|
+
# An experiment executes every dataset item through a +runner+ (a
|
|
8
|
+
# callable that turns an input into the agent's output), optionally
|
|
9
|
+
# scores each output, and collects per-item results. Running the same
|
|
10
|
+
# dataset with two different runners (a changed prompt, a different
|
|
11
|
+
# model) and comparing the experiments is the A/B-testing loop of
|
|
12
|
+
# eval-driven development.
|
|
13
|
+
#
|
|
14
|
+
# @example
|
|
15
|
+
# run_a = dataset.experiment(runner: ->(input) { agent(input, prompt: old) },
|
|
16
|
+
# scorer: judge_scorer)
|
|
17
|
+
# run_b = dataset.experiment(runner: ->(input) { agent(input, prompt: new) },
|
|
18
|
+
# scorer: judge_scorer)
|
|
19
|
+
# run_a.run
|
|
20
|
+
# run_a.summary # => {total:, passed:, failed:, avg_score:, ...}
|
|
21
|
+
# run_a.compare(run_b) # => per-item side-by-side + aggregate verdict
|
|
22
|
+
class Experiment
|
|
23
|
+
# One item's outcome: the produced output, optional score (0..1),
|
|
24
|
+
# error (when the runner raised), and wall-clock duration.
|
|
25
|
+
Result = Data.define(:item, :output, :score, :error, :duration_ms) do
|
|
26
|
+
# @return [Boolean] no error and, when scored, at least 0.5
|
|
27
|
+
def passed?
|
|
28
|
+
error.nil? && (score.nil? || score >= 0.5)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @param dataset [Ask::Eval::Dataset]
|
|
33
|
+
# @param runner [Proc] called with the item's input; returns the
|
|
34
|
+
# agent's output string
|
|
35
|
+
# @param scorer [Proc, nil] called with (input:, output:, expected:);
|
|
36
|
+
# returns a score (0..1) or nil to skip scoring
|
|
37
|
+
def initialize(dataset, runner:, scorer: nil)
|
|
38
|
+
@dataset = dataset
|
|
39
|
+
@runner = runner
|
|
40
|
+
@scorer = scorer
|
|
41
|
+
@results = nil
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# @return [Ask::Eval::Dataset]
|
|
45
|
+
attr_reader :dataset
|
|
46
|
+
|
|
47
|
+
# Execute every dataset item through the runner. A runner error is
|
|
48
|
+
# recorded on that item and the run continues.
|
|
49
|
+
#
|
|
50
|
+
# @return [self]
|
|
51
|
+
def run
|
|
52
|
+
@results = @dataset.items.map do |item|
|
|
53
|
+
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
54
|
+
output = nil
|
|
55
|
+
error = nil
|
|
56
|
+
begin
|
|
57
|
+
output = @runner.call(item.input).to_s
|
|
58
|
+
rescue StandardError => e
|
|
59
|
+
error = e.message
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
score = nil
|
|
63
|
+
if error.nil? && @scorer
|
|
64
|
+
score = @scorer.call(input: item.input, output: output, expected: item.expected)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000).round
|
|
68
|
+
Result.new(item: item, output: output, score: score, error: error, duration_ms: duration_ms)
|
|
69
|
+
end
|
|
70
|
+
self
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# @return [Array<Result>] per-item results (runs first if needed)
|
|
74
|
+
def results
|
|
75
|
+
@results || run.results
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# @return [Hash] aggregate statistics
|
|
79
|
+
def summary
|
|
80
|
+
rs = results
|
|
81
|
+
scores = rs.filter_map(&:score)
|
|
82
|
+
{
|
|
83
|
+
total: rs.size,
|
|
84
|
+
passed: rs.count(&:passed?),
|
|
85
|
+
failed: rs.count { |r| !r.passed? },
|
|
86
|
+
avg_score: scores.empty? ? nil : (scores.sum.fdiv(scores.size)).round(3),
|
|
87
|
+
total_duration_ms: rs.sum(&:duration_ms)
|
|
88
|
+
}
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Side-by-side comparison with another experiment over the same (or
|
|
92
|
+
# overlapping) dataset items.
|
|
93
|
+
#
|
|
94
|
+
# @param other [Ask::Eval::Experiment]
|
|
95
|
+
# @return [Hash] {deltas: [{item_id:, input:, a:, b:, delta:}],
|
|
96
|
+
# a: summary, b: summary, verdict: "a"|"b"|"tie"}
|
|
97
|
+
def compare(other)
|
|
98
|
+
a = results
|
|
99
|
+
b = other.results
|
|
100
|
+
ha = a.to_h { |r| [r.item.id, r] }
|
|
101
|
+
hb = b.to_h { |r| [r.item.id, r] }
|
|
102
|
+
|
|
103
|
+
deltas = (ha.keys & hb.keys).map do |id|
|
|
104
|
+
ra = ha[id]
|
|
105
|
+
rb = hb[id]
|
|
106
|
+
{
|
|
107
|
+
item_id: id,
|
|
108
|
+
input: ra.item.input,
|
|
109
|
+
a: { output: ra.output, score: ra.score, error: ra.error },
|
|
110
|
+
b: { output: rb.output, score: rb.score, error: rb.error },
|
|
111
|
+
delta: (ra.score && rb.score) ? (rb.score - ra.score).round(3) : nil
|
|
112
|
+
}
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
{ deltas: deltas, a: summary, b: other.summary, verdict: verdict(summary, other.summary) }
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
private
|
|
119
|
+
|
|
120
|
+
def verdict(a, b)
|
|
121
|
+
return nil if a[:avg_score].nil? || b[:avg_score].nil?
|
|
122
|
+
return "a" if a[:avg_score] > b[:avg_score]
|
|
123
|
+
return "b" if b[:avg_score] > a[:avg_score]
|
|
124
|
+
|
|
125
|
+
"tie"
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
data/lib/ask/eval/recorder.rb
CHANGED
data/lib/ask/eval/version.rb
CHANGED
data/lib/ask/eval.rb
CHANGED
|
@@ -14,6 +14,8 @@ module Ask
|
|
|
14
14
|
autoload :Configuration, "ask/eval/configuration"
|
|
15
15
|
autoload :Recorder, "ask/eval/recorder"
|
|
16
16
|
autoload :SessionEval, "ask/eval/session_eval"
|
|
17
|
+
autoload :Dataset, "ask/eval/dataset"
|
|
18
|
+
autoload :Experiment, "ask/eval/experiment"
|
|
17
19
|
|
|
18
20
|
# These are loaded eagerly since they define sub-modules with autoloads
|
|
19
21
|
require_relative "eval/assertions"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ask-eval
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Kaka Ruto
|
|
@@ -51,6 +51,20 @@ dependencies:
|
|
|
51
51
|
- - "~>"
|
|
52
52
|
- !ruby/object:Gem::Version
|
|
53
53
|
version: '3.0'
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: ask-core
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - ">="
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '0.1'
|
|
61
|
+
type: :development
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - ">="
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '0.1'
|
|
54
68
|
description: 'Test LLM outputs with Minitest-native assertions. LLM-as-judge for faithfulness,
|
|
55
69
|
hallucination, bias, toxicity. Deterministic assertions (contains, regex, JSON).
|
|
56
70
|
CI-native: GitHub annotations, JUnit output, cost tracking.'
|
|
@@ -70,7 +84,9 @@ files:
|
|
|
70
84
|
- lib/ask/eval/assertions/judge.rb
|
|
71
85
|
- lib/ask/eval/configuration.rb
|
|
72
86
|
- lib/ask/eval/cost_tracker.rb
|
|
87
|
+
- lib/ask/eval/dataset.rb
|
|
73
88
|
- lib/ask/eval/dsl.rb
|
|
89
|
+
- lib/ask/eval/experiment.rb
|
|
74
90
|
- lib/ask/eval/judge.rb
|
|
75
91
|
- lib/ask/eval/judges/bias.rb
|
|
76
92
|
- lib/ask/eval/judges/correctness.rb
|