Module: Insika::Evals::GoldenLoader
- Defined in:
- lib/insika/evals/golden.rb
Overview
Loads + validates golden files. Fails LOUD on a malformed case — a silently dropped golden is a hole in the safety net.
Defined Under Namespace
Classes: InvalidGolden
Class Method Summary collapse
-
.build(raw, source: "(inline)") ⇒ Object
hash (string keys) -> validated Golden.
-
.load_dir(dir) ⇒ Object
Loads every .yml/.yaml under
dir(recursive), sorted by path for a stable run order. - .load_file(path) ⇒ Object
-
.normalize_persona(raw, id:, source:) ⇒ Object
persona:is the alternative shape toturns:: the conversation is GENERATED, not replayed. -
.normalize_reference(raw, id:, source:) ⇒ Object
reference: { "source" => String?, "messages" => [{ "role" =>, "text" =>, "origin" => }] }.
-
.normalize_turns(turns, id:, source:) ⇒ Object
turns: a non-empty array of { "user" => String }.
- .presence(v) ⇒ Object
- .reference_message(raw, index, id:, source:) ⇒ Object
-
.validate_policy!(value, id:, source:) ⇒ Object
A typo'd policy must not silently mean "no policy" — the case would go on passing while the rule it was written for stopped being checked.
Class Method Details
.build(raw, source: "(inline)") ⇒ Object
hash (string keys) -> validated Golden. source is only for error messages.
99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 |
# File 'lib/insika/evals/golden.rb', line 99 def build(raw, source: "(inline)") raise InvalidGolden, "#{source}: golden must be a mapping" unless raw.is_a?(Hash) id = presence(raw["id"]) || (raise InvalidGolden, "#{source}: 'id' is required") agent = presence(raw["agent"]) || (raise InvalidGolden, "#{source}: 'agent' is required (case '#{id}')") persona = normalize_persona(raw["persona"], id: id, source: source) if persona && !raw["turns"].nil? raise InvalidGolden, "#{source}: a case is ONE shape — 'turns' or 'persona', not both (case '#{id}')" end turns = persona ? [] : normalize_turns(raw["turns"], id: id, source: source) expect = raw["expect"] || {} raise InvalidGolden, "#{source}: 'expect' must be a mapping (case '#{id}')" unless expect.is_a?(Hash) validate_policy!(expect["policy"], id: id, source: source) requires = raw["requires"] || {} unless requires.is_a?(Hash) raise InvalidGolden, "#{source}: 'requires' must be a mapping (case '#{id}')" end reference = normalize_reference(raw["reference"], id: id, source: source) tenant = presence(raw["tenant"]) || "platform" Golden.new(id: id, agent: agent, turns: turns, expect: expect, requires: requires, reference: reference, source: source, persona: persona, tenant: tenant) end |
.load_dir(dir) ⇒ Object
Loads every .yml/.yaml under dir (recursive), sorted by path for a stable
run order. -> [Golden].
87 88 89 |
# File 'lib/insika/evals/golden.rb', line 87 def load_dir(dir) Dir.glob(File.join(dir, "**", "*.{yml,yaml}")).sort.map { |f| load_file(f) } end |
.load_file(path) ⇒ Object
91 92 93 94 95 96 |
# File 'lib/insika/evals/golden.rb', line 91 def load_file(path) raw = YAML.safe_load(File.read(path), permitted_classes: [], aliases: false) || {} build(raw, source: path) rescue Psych::SyntaxError => e raise InvalidGolden, "#{path}: invalid YAML — #{e.}" end |
.normalize_persona(raw, id:, source:) ⇒ Object
persona: is the alternative shape to turns:: the
conversation is GENERATED, not replayed. Malformed is REFUSED — a persona
without knows or max_turns would simulate nothing. The PersonaLoader
already prefixes its messages with the source path; the case id is added
ONCE here (the loader is shared by the persona-file CLI, which has no case
shape).
133 134 135 136 137 138 139 |
# File 'lib/insika/evals/golden.rb', line 133 def normalize_persona(raw, id:, source:) return nil if raw.nil? PersonaLoader.build(raw, source: source) rescue PersonaLoader::InvalidPersona => e raise InvalidGolden, "#{e.} (case '#{id}')" end |
.normalize_reference(raw, id:, source:) ⇒ Object
reference: { "source" => String?, "messages" => [{ "role" =>, "text" =>, "origin" => }] }. Absent -> {}, and the case simply has nothing to compare against. Malformed is REFUSED: a reference that half-loads would produce a pairwise verdict about a transcript nobody wrote.
145 146 147 148 149 150 151 152 153 154 155 156 |
# File 'lib/insika/evals/golden.rb', line 145 def normalize_reference(raw, id:, source:) return {} if raw.nil? raise InvalidGolden, "#{source}: 'reference' must be a mapping (case '#{id}')" unless raw.is_a?(Hash) = raw["messages"] unless .is_a?(Array) && !.empty? raise InvalidGolden, "#{source}: reference needs a non-empty 'messages' array (case '#{id}')" end { "source" => presence(raw["source"]), "messages" => .each_with_index.map { |m, i| (m, i, id: id, source: source) } }.compact end |
.normalize_turns(turns, id:, source:) ⇒ Object
turns: a non-empty array of { "user" => String }. Rejects anything else so a
typo (e.g. users:) surfaces at load time, not as an empty replay.
191 192 193 194 195 196 197 198 199 200 201 |
# File 'lib/insika/evals/golden.rb', line 191 def normalize_turns(turns, id:, source:) unless turns.is_a?(Array) && !turns.empty? raise InvalidGolden, "#{source}: 'turns' must be a non-empty array (case '#{id}')" end turns.each_with_index.map do |t, i| user = t.is_a?(Hash) ? presence(t["user"]) : nil user || (raise InvalidGolden, "#{source}: turns[#{i}] needs a non-empty 'user' (case '#{id}')") { "user" => user } end end |
.presence(v) ⇒ Object
203 204 205 206 |
# File 'lib/insika/evals/golden.rb', line 203 def presence(v) s = v.to_s.strip s.empty? ? nil : s end |
.reference_message(raw, index, id:, source:) ⇒ Object
158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 |
# File 'lib/insika/evals/golden.rb', line 158 def (raw, index, id:, source:) where = "#{source}: reference.messages[#{index}] (case '#{id}')" raise InvalidGolden, "#{where} must be a mapping" unless raw.is_a?(Hash) role = presence(raw["role"]) raise InvalidGolden, "#{where} needs a 'role' of user or assistant" unless %w[user assistant].include?(role) text = presence(raw["text"]) || (raise InvalidGolden, "#{where} needs a non-empty 'text'") # The SAME closed vocabulary the engine stamps. A typo'd marker would # read as "absent" downstream, which is how a human turn gets scored as the # incumbent's model. origin = begin MessageOrigin.parse!(raw["origin"]) rescue Insika::ValidationError => e raise InvalidGolden, "#{where}: #{e.}" end { "role" => role, "text" => text }.merge(origin ? { "origin" => origin } : {}) end |
.validate_policy!(value, id:, source:) ⇒ Object
A typo'd policy must not silently mean "no policy" — the case would go on
passing while the rule it was written for stopped being checked. The
Assertions constant is resolved at CALL time (this file loads first, and
assertions.rb touches Safety::Detectors at load time).
181 182 183 184 185 186 187 |
# File 'lib/insika/evals/golden.rb', line 181 def validate_policy!(value, id:, source:) name = presence(value) return if name.nil? || Assertions::POLICIES.key?(name) raise InvalidGolden, "#{source}: unknown policy #{name.inspect} (case '#{id}') — " \ "known: #{Assertions::POLICIES.keys.join(', ')}" end |