Class: Insika::Evals::Judge
- Inherits:
-
Object
- Object
- Insika::Evals::Judge
- Defined in:
- lib/insika/evals/judge.rb
Overview
The LLM-judge (RFC-0008 §3.3, Fase B). Scores a golden's rubric against the
actual assistant reply — the subjective layer on top of the deterministic
asserts. Pure over an injected ask callable (prompt -> raw model text), so it's
unit-testable without an LLM; the real ask (RubyLLM on the utility_model, temp 0)
is built by the CLI.
Conservative by construction: an unparseable judge reply scores 0 (fails) rather than silently passing.
A PANEL, not a single voice (RFC-0013 §3.9). quorum: N samples ONE model N
times, which measures that model's variance and little else — at temperature 0 it
mostly returns the same answer, including the same blind spot. Two DIFFERENT
models disagreeing about a rubric is the signal worth having, so asks: takes one
callable per model: each judge is scored independently (its own samples, its own
median, its own pass/fail against the case's min_score), then
• aggregate combines the scores into the one the report and the baseline
read (:median | :mean | :min — :min is the strict panel),
• min_agreement decides the verdict: the FRACTION of judges that must pass on
their own. 0.5 = a majority; 1.0 = unanimous.
quorum still applies, per judge, so a panel can also be sampled.
Defined Under Namespace
Classes: Verdict
Constant Summary collapse
- DEFAULT_MIN_SCORE =
0.7- AGGREGATES =
%i[median mean min].freeze
- POLICY_INSTRUCTIONS =
The store's operating policy, stated to the judge in the same words the deterministic layer checks. Empty when the store has no opinion — then the rubric alone decides, and inventing a default here would be inventing an opinion for someone else's store.
{ "ask_once" => "This store allows AT MOST ONE question per reply. Two questions in " \ "one message is a failure even if the content is otherwise good.", "investigate_first" => "This store wants the objective established BEFORE acting: on a " \ "vague request the assistant should ask (once or twice, not a " \ "form), not search immediately.", "act_fast" => "This store wants the assistant to ACT on the first plausible reading and " \ "refine after — asking something it could have answered by searching is a " \ "failure." }.freeze
Instance Method Summary collapse
-
#initialize(ask: nil, asks: nil, quorum: 1, aggregate: :median, min_agreement: 0.5) ⇒ Judge
constructor
ask: ->(prompt) { "
" } — one judge (kept: the common case). -
#score(golden:, result:) ⇒ Object
Golden + the LAST TurnResult -> Verdict, or nil when there's nothing to judge (no rubric).
Constructor Details
#initialize(ask: nil, asks: nil, quorum: 1, aggregate: :median, min_agreement: 0.5) ⇒ Judge
ask: ->(prompt) { "
52 53 54 55 56 57 58 59 60 61 |
# File 'lib/insika/evals/judge.rb', line 52 def initialize(ask: nil, asks: nil, quorum: 1, aggregate: :median, min_agreement: 0.5) @asks = Array(asks || ask).compact raise ArgumentError, "a judge needs at least one `ask`" if @asks.empty? @quorum = [quorum.to_i, 1].max @aggregate = aggregate.to_s.to_sym raise ArgumentError, "unknown aggregate: #{aggregate}" unless AGGREGATES.include?(@aggregate) @min_agreement = min_agreement.to_f.clamp(0.0, 1.0) end |
Instance Method Details
#score(golden:, result:) ⇒ Object
Golden + the LAST TurnResult -> Verdict, or nil when there's nothing to judge
(no rubric). min_score comes from the golden (default 0.7).
65 66 67 68 69 70 71 72 73 74 75 76 77 78 |
# File 'lib/insika/evals/judge.rb', line 65 def score(golden:, result:) rubric = golden.rubric.to_s.strip return nil if rubric.empty? prompt = build_prompt(rubric, golden.user_turns, result.output_text.to_s, golden.policy) min = golden.min_score || DEFAULT_MIN_SCORE panel = @asks.map { |ask| judge_once(ask, prompt, min) } agreed = panel.count { |j| j[:pass] } Verdict.new(score: combine(panel.map { |j| j[:score] }).round(3), pass: (agreed.to_f / panel.length) >= @min_agreement, reason: panel.map { |j| j[:reason] }.reject(&:empty?).first.to_s, judges: panel.map { |j| j[:score] }) end |