Class: Insika::HarvestStore
- Inherits:
-
Object
- Object
- Insika::HarvestStore
- Defined in:
- lib/insika/harvest_store.rb
Overview
the durable half of the harvest. One record per mining RUN (window, candidates, cost), the per-candidate lifecycle (the unit a human acts on — D8), the APPEND-ONLY promotion log, the pre-promotion snapshots, and the per-session mined markers (D10's re-scan discipline). A dumb domain store: no policy (which candidate is worth gating is the gates'), no model, no skills — the SkillStore stays the skill's home.
Key shapes (string keys, Store-contract JSON):
"harvest" "run:<agent>:<started_at>:<id>" -> the mining run
"harvest" "cand:<id>" -> one candidate lifecycle
"harvest" "promo:<agent>:<at>:<id>" -> APPEND ONLY (D8)
"harvest" "snap:<id>" -> the pre-promotion state
"harvest" "session:<session_ref>" -> the D10 marker
The agent id must not contain ":" (the run-key split — the RefinementStore
rule). The body is the only content the store holds: behavior
instructions, the same trust level as SkillStore content (D11).
Defined Under Namespace
Classes: Candidate, Promotion, Run, Snapshot
Constant Summary collapse
- SCOPE =
"harvest"- RUN_STATUSES =
Run statuses: mining -> completed | no_candidates | failed. The run does NOT carry awaiting_approval: several candidates of one run can be at different gates (D8) — the human answers CANDIDATES, the run only frames.
%w[mining completed no_candidates failed].freeze
- CANDIDATE_STATUSES =
Candidate statuses (the lifecycle a human acts on):
pending -> gated -> awaiting_approval -> promoted | rejected (gated FAIL -> rejected with the report; the latch) %w[pending gated awaiting_approval promoted rejected].freeze
- OPEN_STATUSES =
A candidate is OPEN (dedup-suppressing) until terminal.
%w[pending gated awaiting_approval].freeze
Instance Method Summary collapse
-
#append_promotion(id:, agent:, skill:, origin: [], eval_ref: nil, conversion_ref: nil, approver:, snapshot_ref: nil, criterion_sha: nil, at: nil) ⇒ Object
Appends the promotion row — NO update, NO delete, no re-key.
-
#append_rollback(promotion_id:, operator: nil, reason: nil) ⇒ Object
Stamps rolled_back_at on the row — the log stays the single ledger (D9).
-
#attach_gate(candidate_id, eval_gate:, conversion_gate:, criterion_sha:) ⇒ Object
Records both gate reports + the boot-loaded criterion sha; pending -> gated.
-
#attach_rejected(candidate_id, rules:, reason:) ⇒ Object
Appends a { rule, reason } rejection entry (the negative-list / gate evidence lines).
-
#awaiting_approval(limit: 50) ⇒ Object
-> [Candidate] EVERY candidate parked on a human, most recent first.
-
#candidates(agent_id: nil, status: nil) ⇒ Object
-> [Candidate] filtered by agent/status, most recent first.
-
#complete_run(id, candidates:, cost: nil, rejected: nil) ⇒ Object
Closes a run with its candidate count.
-
#create_candidate(run_id:, agent:, name:, description:, body:, triggers: [], rationale:, origin:, evidence_turns: [], proposer:, id: SecureRandom.uuid) ⇒ Object
The engine stamps agent/origin/proposer — never the model (D3).
-
#create_run(agent_id:, window: {}, budget: nil, id: SecureRandom.uuid) ⇒ Object
Opens a run (:mining).
-
#create_snapshot(agent:, skill:, content:, existed:, enabled_for:) ⇒ Object
The pre-promotion state (D8 — snapshot FIRST, then the writes).
-
#delete_older_than(time) ⇒ Object
Candidates (pending AND terminal), log rows, snapshots and runs past the cutoff.
-
#fail_run(id, error:) ⇒ Object
Closes a run as :failed, recording the error.
- #find_candidate(id) ⇒ Object
- #find_run(id) ⇒ Object
- #find_snapshot(id) ⇒ Object
-
#initialize(store:, session_store: nil) ⇒ HarvestStore
constructor
session_store: optional — the session source for
unmined_sessions(D10). -
#mark_awaiting(candidate_id) ⇒ Object
gated -> awaiting_approval.
-
#mark_mined(session_ref, candidates: 0) ⇒ Object
---- the per-session marker (D10 - the re-scan discipline) ----------------.
-
#mark_promoted(candidate_id, promotion_ref:) ⇒ Object
awaiting_approval -> promoted.
-
#mark_rejected(candidate_id, operator:, note: nil) ⇒ Object
-> rejected (terminal) from pending | gated | awaiting_approval — a human may always outvote the miner.
- #mined?(session_ref) ⇒ Boolean
-
#open_pending?(agent:, name:) ⇒ Boolean
Dedup: an OPEN (non-terminal) candidate with the same (agent, name) suppresses a re-proposal.
-
#promotions(agent_id: nil, limit: 100) ⇒ Object
-> [Promotion] new-first, optionally per agent, capped.
-
#purge(tenant:) ⇒ Object
The tenant is the store's (candidates reference sessions; sessions carry the tenant prefix) — a prefix scan over the scope keys.
-
#recheck_conversion(candidate_id, conversion_gate:) ⇒ Object
D8-bis: the conversion ruler is re-read AT APPROVE TIME and may have moved below the threshold since gating.
-
#runs_for(agent_id, limit: 20) ⇒ Object
-> [Run] for one agent, MOST RECENT FIRST, capped.
-
#unmined_sessions(since: nil) ⇒ Object
The engine's scan space: every session the source knows MINUS the marked set, optionally bounded by
since(sessions updated at/after it — the incremental boundary).
Constructor Details
#initialize(store:, session_store: nil) ⇒ HarvestStore
session_store: optional — the session source for unmined_sessions
(D10). nil = the scan is inert (the engine may enumerate sessions
itself; the store never invents a session space).
53 54 55 56 |
# File 'lib/insika/harvest_store.rb', line 53 def initialize(store:, session_store: nil) @store = store @sessions = session_store end |
Instance Method Details
#append_promotion(id:, agent:, skill:, origin: [], eval_ref: nil, conversion_ref: nil, approver:, snapshot_ref: nil, criterion_sha: nil, at: nil) ⇒ Object
Appends the promotion row — NO update, NO delete, no re-key. A second row with the same id is refused loudly.
248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 |
# File 'lib/insika/harvest_store.rb', line 248 def append_promotion(id:, agent:, skill:, origin: [], eval_ref: nil, conversion_ref: nil, approver:, snapshot_ref: nil, criterion_sha: nil, at: nil) id = id.to_s agent = agent.to_s raise Insika::ValidationError, "promotion id is required" if id.empty? raise Insika::ValidationError, "promotion already recorded: #{id}" if find_promotion_key(id) now = at || record = { "id" => id, "agent" => agent, "skill" => skill.to_s, "origin" => Array(origin).map(&:to_s), "eval_ref" => eval_ref.to_s, "conversion_ref" => conversion_ref.to_s, "approver" => (Coercion.presence(approver) || "operator").to_s, "snapshot_ref" => snapshot_ref.to_s, "criterion_sha" => criterion_sha.to_s, "rolled_back_at" => nil, "at" => now } @store.set(SCOPE, "promo:#{agent}:#{now}:#{id}", record) to_promotion(record) end |
#append_rollback(promotion_id:, operator: nil, reason: nil) ⇒ Object
Stamps rolled_back_at on the row — the log stays the single ledger (D9). -> Promotion.
270 271 272 273 274 275 276 277 278 |
# File 'lib/insika/harvest_store.rb', line 270 def append_rollback(promotion_id:, operator: nil, reason: nil) key = find_promotion_key(promotion_id.to_s) raise Insika::NotFoundError, "promotion not found: #{promotion_id}" if key.nil? record = @store.get(SCOPE, key) record["rolled_back_at"] = @store.set(SCOPE, key, record) to_promotion(record) end |
#attach_gate(candidate_id, eval_gate:, conversion_gate:, criterion_sha:) ⇒ Object
Records both gate reports + the boot-loaded criterion sha; pending -> gated.
186 187 188 189 190 191 192 193 194 195 |
# File 'lib/insika/harvest_store.rb', line 186 def attach_gate(candidate_id, eval_gate:, conversion_gate:, criterion_sha:) update_candidate(candidate_id) do |record| guard_candidate_state!(record, "pending") record["eval_gate"] = Coercion.deep_stringify(eval_gate) record["conversion_gate"] = Coercion.deep_stringify(conversion_gate) record["criterion_sha"] = criterion_sha.to_s record["status"] = "gated" record["updated_at"] = end end |
#attach_rejected(candidate_id, rules:, reason:) ⇒ Object
Appends a { rule, reason } rejection entry (the negative-list / gate evidence lines). Accumulates; only on a non-terminal candidate.
176 177 178 179 180 181 182 |
# File 'lib/insika/harvest_store.rb', line 176 def attach_rejected(candidate_id, rules:, reason:) update_candidate(candidate_id) do |record| guard_candidate_non_terminal!(record) list = record["rejected"] || [] record["rejected"] = list + Array(rules).map { |r| { "rule" => r.to_s, "reason" => reason.to_s } } end end |
#awaiting_approval(limit: 50) ⇒ Object
-> [Candidate] EVERY candidate parked on a human, most recent first.
154 155 156 157 |
# File 'lib/insika/harvest_store.rb', line 154 def awaiting_approval(limit: 50) list = candidates(status: "awaiting_approval").sort_by { |c| c.updated_at.to_s }.reverse list.first(limit) end |
#candidates(agent_id: nil, status: nil) ⇒ Object
-> [Candidate] filtered by agent/status, most recent first.
146 147 148 149 150 151 |
# File 'lib/insika/harvest_store.rb', line 146 def candidates(agent_id: nil, status: nil) scan_candidates.select do |c| (agent_id.nil? || c.agent == agent_id.to_s) && (status.nil? || c.status == status.to_s) end end |
#complete_run(id, candidates:, cost: nil, rejected: nil) ⇒ Object
Closes a run with its candidate count. Zero candidates -> :no_candidates
(a distinct outcome from :completed — "we looked and it was clean").
rejected is the { reason/rule => count } map the filters counted (D4:
"every rejected-by-list candidate is logged with the matching rule").
-> Run. ArgumentError when the run is already terminal.
86 87 88 89 90 91 92 93 94 95 |
# File 'lib/insika/harvest_store.rb', line 86 def complete_run(id, candidates:, cost: nil, rejected: nil) update_run(id) do |record| guard_run_state!(record, "mining") record["candidates"] = Integer(candidates) record["rejected"] = rejected ? Coercion.deep_stringify(rejected) : {} record["cost"] = Coercion.deep_stringify(cost) if cost record["status"] = Integer(candidates).positive? ? "completed" : "no_candidates" record["finished_at"] = end end |
#create_candidate(run_id:, agent:, name:, description:, body:, triggers: [], rationale:, origin:, evidence_turns: [], proposer:, id: SecureRandom.uuid) ⇒ Object
The engine stamps agent/origin/proposer — never the model (D3).
122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 |
# File 'lib/insika/harvest_store.rb', line 122 def create_candidate(run_id:, agent:, name:, description:, body:, triggers: [], rationale:, origin:, evidence_turns: [], proposer:, id: SecureRandom.uuid) now = record = { "id" => id.to_s, "run_id" => run_id.to_s, "agent" => agent.to_s, "name" => name.to_s, "description" => description.to_s, "body" => body.to_s, "triggers" => Array(triggers).map(&:to_s), "rationale" => rationale.to_s, "origin" => Array(origin).map(&:to_s), "evidence_turns" => Array(evidence_turns).map(&:to_i), "proposer" => proposer.to_s, "status" => "pending", "rejected" => [], "eval_gate" => nil, "conversion_gate" => nil, "criterion_sha" => nil, "decision" => nil, "promotion_ref" => nil, "created_at" => now, "updated_at" => now } @store.set(SCOPE, "cand:#{id}", record) to_candidate(record) end |
#create_run(agent_id:, window: {}, budget: nil, id: SecureRandom.uuid) ⇒ Object
Opens a run (:mining). window is the miner's window as data
({ "last_sessions" => N } | { "since" => iso8601 } | { "session_ids" => [...] }).
budget is the pack's harvest.miner.budget (the cap the gates read —
the refinement budget discipline). -> Run.
64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 |
# File 'lib/insika/harvest_store.rb', line 64 def create_run(agent_id:, window: {}, budget: nil, id: SecureRandom.uuid) agent = agent_id.to_s raise Insika::ValidationError, "agent_id is required" if agent.empty? raise Insika::ValidationError, "agent_id must not contain ':'" if agent.include?(":") started = record = { "id" => id.to_s, "agent_id" => agent, "status" => "mining", "window" => Coercion.deep_stringify(window || {}), "candidates" => 0, "rejected" => {}, "budget" => Coercion.deep_stringify(budget), "cost" => nil, "started_at" => started, "finished_at" => nil, "error" => nil } @store.set(SCOPE, "run:#{agent}:#{started}:#{id}", record) to_run(record) end |
#create_snapshot(agent:, skill:, content:, existed:, enabled_for:) ⇒ Object
The pre-promotion state (D8 — snapshot FIRST, then the writes).
288 289 290 291 292 293 294 295 296 |
# File 'lib/insika/harvest_store.rb', line 288 def create_snapshot(agent:, skill:, content:, existed:, enabled_for:) id = SecureRandom.uuid record = { "id" => id, "agent" => agent.to_s, "skill" => skill.to_s, "content" => content, "existed" => !!existed, "enabled_for" => Array(enabled_for).map(&:to_s), "at" => } @store.set(SCOPE, "snap:#{id}", record) to_snapshot(record) end |
#delete_older_than(time) ⇒ Object
Candidates (pending AND terminal), log rows, snapshots and runs past the cutoff. They are re-derivable (D2) — pruning is never data loss. The session MARKERS are never pruned (the marker is the claim). -> count removed.
391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 |
# File 'lib/insika/harvest_store.rb', line 391 def delete_older_than(time) cutoff = Time.parse(time.to_s).utc removed = 0 [["cand:", "created_at"], ["snap:", "at"], ["run:", "started_at"]].each do |prefix, field| @store.list(SCOPE, prefix).each do |k| record = @store.get(SCOPE, k) next unless record && record[field] begin next unless Time.parse(record[field].to_s).utc < cutoff rescue ArgumentError next end @store.delete(SCOPE, k) removed += 1 end end @store.list(SCOPE, "promo:").each do |k| record = @store.get(SCOPE, k) next unless record && record["at"] begin next unless Time.parse(record["at"].to_s).utc < cutoff rescue ArgumentError next end @store.delete(SCOPE, k) removed += 1 end removed end |
#fail_run(id, error:) ⇒ Object
Closes a run as :failed, recording the error. -> Run.
98 99 100 101 102 103 104 105 |
# File 'lib/insika/harvest_store.rb', line 98 def fail_run(id, error:) update_run(id) do |record| guard_run_state!(record, "mining") record["status"] = "failed" record["error"] = error.to_s record["finished_at"] = end end |
#find_candidate(id) ⇒ Object
141 142 143 |
# File 'lib/insika/harvest_store.rb', line 141 def find_candidate(id) to_candidate(@store.get(SCOPE, "cand:#{id}")) end |
#find_run(id) ⇒ Object
107 108 109 110 |
# File 'lib/insika/harvest_store.rb', line 107 def find_run(id) key = run_key(id) key && to_run(@store.get(SCOPE, key)) end |
#find_snapshot(id) ⇒ Object
298 299 300 |
# File 'lib/insika/harvest_store.rb', line 298 def find_snapshot(id) to_snapshot(@store.get(SCOPE, "snap:#{id}")) end |
#mark_awaiting(candidate_id) ⇒ Object
gated -> awaiting_approval. The refusal parks: a conversion REFUSAL keeps the candidate AT gated (the page shows the ruler's hole).
199 200 201 202 203 204 205 |
# File 'lib/insika/harvest_store.rb', line 199 def mark_awaiting(candidate_id) update_candidate(candidate_id) do |record| guard_candidate_state!(record, "gated") record["status"] = "awaiting_approval" record["updated_at"] = end end |
#mark_mined(session_ref, candidates: 0) ⇒ Object
---- the per-session marker (D10 - the re-scan discipline) ----------------
304 305 306 307 308 309 310 311 312 |
# File 'lib/insika/harvest_store.rb', line 304 def mark_mined(session_ref, candidates: 0) ref = session_ref.to_s return {} if ref.empty? record = { "session_ref" => ref, "mined_at" => , "candidates" => Integer(candidates) } @store.set(SCOPE, "session:#{ref}", record) record end |
#mark_promoted(candidate_id, promotion_ref:) ⇒ Object
awaiting_approval -> promoted. Recorded ONLY after the writes land (the record-after rule).
221 222 223 224 225 226 227 228 |
# File 'lib/insika/harvest_store.rb', line 221 def mark_promoted(candidate_id, promotion_ref:) update_candidate(candidate_id) do |record| guard_candidate_state!(record, "awaiting_approval") record["status"] = "promoted" record["promotion_ref"] = promotion_ref.to_s record["updated_at"] = end end |
#mark_rejected(candidate_id, operator:, note: nil) ⇒ Object
-> rejected (terminal) from pending | gated | awaiting_approval — a human may always outvote the miner. The decision records by/at/note.
209 210 211 212 213 214 215 216 217 |
# File 'lib/insika/harvest_store.rb', line 209 def mark_rejected(candidate_id, operator:, note: nil) update_candidate(candidate_id) do |record| guard_candidate_non_terminal!(record) record["status"] = "rejected" record["decision"] = { "by" => (Coercion.presence(operator) || "operator").to_s, "at" => , "note" => Coercion.presence(note) }.compact record["updated_at"] = end end |
#mined?(session_ref) ⇒ Boolean
314 315 316 |
# File 'lib/insika/harvest_store.rb', line 314 def mined?(session_ref) !@store.get(SCOPE, "session:#{session_ref.to_s}").nil? end |
#open_pending?(agent:, name:) ⇒ Boolean
Dedup: an OPEN (non-terminal) candidate with the same (agent, name) suppresses a re-proposal. -> bool.
161 162 163 164 165 166 167 168 169 |
# File 'lib/insika/harvest_store.rb', line 161 def open_pending?(agent:, name:) @store.list(SCOPE, "cand:").any? do |k| record = @store.get(SCOPE, k) next false unless record next false unless record["agent"] == agent.to_s && record["name"] == name.to_s OPEN_STATUSES.include?(record["status"].to_s) end end |
#promotions(agent_id: nil, limit: 100) ⇒ Object
-> [Promotion] new-first, optionally per agent, capped.
281 282 283 284 285 |
# File 'lib/insika/harvest_store.rb', line 281 def promotions(agent_id: nil, limit: 100) keys = @store.list(SCOPE, agent_id ? "promo:#{agent_id}:" : "promo:") rows = keys.filter_map { |k| to_promotion(@store.get(SCOPE, k)) } rows.sort_by { |p| p.at.to_s }.reverse.first(limit) end |
#purge(tenant:) ⇒ Object
The tenant is the store's (candidates reference sessions; sessions carry the tenant prefix) — a prefix scan over the scope keys. Removes the tenant's candidates, promotion rows, their snapshots and the session markers. -> count removed.
346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 |
# File 'lib/insika/harvest_store.rb', line 346 def purge(tenant:) prefix = "#{tenant_id(tenant)}:" removed = 0 promo_refs = [] @store.list(SCOPE, "promo:").each do |k| record = @store.get(SCOPE, k) next unless record && Array(record["origin"]).any? { |o| o.to_s.start_with?(prefix) } @store.delete(SCOPE, k) promo_refs << record["snapshot_ref"].to_s removed += 1 end @store.list(SCOPE, "cand:").each do |k| record = @store.get(SCOPE, k) next unless record && Array(record["origin"]).any? { |o| o.to_s.start_with?(prefix) } @store.delete(SCOPE, k) removed += 1 end promo_refs.each do |ref| next if ref.empty? snap_key = "snap:#{ref}" existing = @store.get(SCOPE, snap_key) next unless existing @store.delete(SCOPE, snap_key) removed += 1 end @store.list(SCOPE, "session:#{prefix}").each do |k| @store.delete(SCOPE, k) removed += 1 end removed end |
#recheck_conversion(candidate_id, conversion_gate:) ⇒ Object
D8-bis: the conversion ruler is re-read AT APPROVE TIME and may have moved below the threshold since gating. The promote path re-checks; a dip parks the candidate BACK at gated with the FRESH report — the operator re-decides, and a skill does not land into a ruler that has moved the wrong way. awaiting_approval -> gated.
235 236 237 238 239 240 241 242 |
# File 'lib/insika/harvest_store.rb', line 235 def recheck_conversion(candidate_id, conversion_gate:) update_candidate(candidate_id) do |record| guard_candidate_state!(record, "awaiting_approval") record["conversion_gate"] = Coercion.deep_stringify(conversion_gate) record["status"] = "gated" record["updated_at"] = end end |
#runs_for(agent_id, limit: 20) ⇒ Object
-> [Run] for one agent, MOST RECENT FIRST, capped.
113 114 115 116 117 |
# File 'lib/insika/harvest_store.rb', line 113 def runs_for(agent_id, limit: 20) keys = @store.list(SCOPE, "run:#{agent_id}:").reverse keys = keys.first(limit) if limit keys.filter_map { |k| to_run(@store.get(SCOPE, k)) } end |
#unmined_sessions(since: nil) ⇒ Object
The engine's scan space: every session the source knows MINUS the marked
set, optionally bounded by since (sessions updated at/after it — the
incremental boundary). Nil source = inert (parity — the caller that owns
a SessionStore enumerates itself).
-> [String]
323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 |
# File 'lib/insika/harvest_store.rb', line 323 def unmined_sessions(since: nil) return [] unless @sessions @sessions.each_id.filter_map do |id| next if mined?(id) if since session = @sessions.respond_to?(:find) ? @sessions.find(id) : nil next unless session next unless Time.parse(session.updated_at.to_s).utc >= Time.parse(since.to_s).utc end id.to_s rescue ArgumentError next end.to_a end |