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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 12e438efcef2038b328af35cdb14d6741a7730940f1d8f7014f4550c3b8ac543
4
- data.tar.gz: c32aaf27235fdd33e206ebc4434ed647015c8fa6d81a8b16b88546c36597f4d2
3
+ metadata.gz: d396e80eace74425011173335621532f318eb2921600aa8cfcdf1e79480f4df6
4
+ data.tar.gz: afc9c5a9873ba2b97766cd873211d4e2e94334c9adc8a39b372f4981a84e0fb1
5
5
  SHA512:
6
- metadata.gz: 015cd82d67ac7fcf88c1661d352c459845ffbee6dc373e2d9999db68d8256cb1e63988618e47263ee59eb887fa7381e42b30b0b3fcc4c1fac4cdc802891f08b2
7
- data.tar.gz: 8daf2d3ccc31244b1e3748ee2f1b07970a9c1af12592e9e6bddfdafaea980816961f4099f4382f5704bb47554e8036185ce4131fe4494400b9ba0b59bec4b026
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
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "json"
4
4
  require "fileutils"
5
+ require "time" # Time#iso8601 for recorded_at timestamps
5
6
 
6
7
  module Ask
7
8
  module Eval
@@ -1,5 +1,5 @@
1
1
  module Ask
2
2
  module Eval
3
- VERSION = "0.3.0"
3
+ VERSION = "0.4.0"
4
4
  end
5
5
  end
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.3.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