Class: Insika::RefinementStore

Inherits:
Object
  • Object
show all
Includes:
Coercion
Defined in:
lib/insika/refinement_store.rb

Overview

REFINEMENT DOMAIN store (RFC-0013, phase A). One record per refinement RUN: the window that was read, the ranked findings the EvidenceCollector produced, and the run's outcome. RUNTIME data (it is derived from sessions/tasks/traces), so it takes the raw store: like SessionStore/TaskStore — not the ConfigStore.

The key embeds the agent and the start timestamp:

"run:<agent_id>:<started_at>:<id>"

so list(SCOPE, "run:<agent>:") comes back CHRONOLOGICAL for that agent (the Store contract orders lexicographically) and latest_for is its last element. An agent id containing ":" would break that split, so it is rejected on write.

Phase A wrote no edits anywhere — a Run was a REPORT and every non-collecting status was terminal. Phase C (RFC-0013 §3.2) adds the rest of the lifecycle on the SAME record, additively: a gated candidate and the operator's decision.

collecting ─▶ completed ─▶ gating ─▶ awaiting_approval ─▶ applied
        ╰─▶ no_findings          ╰─▶ rejected  (the gate failed it,
        ╰─▶ failed                    or the operator did)

The approval lives here, not in PendingActionStore — a deliberate deviation from §3.6. That store is coupled to a suspended TURN: ApproveAction resolves the record and then calls executor.approve(task_id) to wake a fiber. A refinement proposal has no turn and no fiber, so reusing it would mean inventing a task id, a fake tool name, and a wake-up that must never do anything — and the operator's approvals inbox would fill with rows that are not tool calls. The property §3.6 actually wanted is durability across a kill -9, and this store has had it since phase A.

Defined Under Namespace

Classes: Run

Constant Summary collapse

SCOPE =
"refinements"
KEY_PREFIX =
"run:"
STATUSES =
%i[collecting completed gating awaiting_approval applied rejected
no_findings failed].freeze
OPEN =

OPEN means "this run will change without anyone asking": work is in flight (collecting, gating) or a human owes it an answer (awaiting_approval). Everything else is terminal — INCLUDING completed, which phase A already treated that way and which stays true: a report is finished, and gating one is a new deliberate action, not a continuation. (Widening terminal? here is what the Studio's "latest report" lookup reads, so getting it wrong hides the report.)

%i[collecting gating awaiting_approval].freeze

Instance Method Summary collapse

Methods included from Coercion

blank?, deep_stringify, presence, present?, utf8

Constructor Details

#initialize(store:) ⇒ RefinementStore

Returns a new instance of RefinementStore.



71
72
73
# File 'lib/insika/refinement_store.rb', line 71

def initialize(store:)
  @store = store
end

Instance Method Details

#awaiting_approval(limit: 20) ⇒ Object

-> [Run] every run parked on a human, most recent first. What the Studio badges.



201
202
203
# File 'lib/insika/refinement_store.rb', line 201

def awaiting_approval(limit: 20)
  recent(limit: 200).select(&:awaiting_approval?).first(limit)
end

#complete(id, findings:, excluded: 0) ⇒ Object

Closes a run with its findings. Empty findings -> :no_findings (a distinct outcome from :completed — "we looked and it was clean" is a real answer, not a failure). -> Run. ArgumentError if the run is already terminal. excluded is how many turns the window dropped on purpose (synthetic traffic) — recorded so a report never reads cleaner than the data was.



100
101
102
103
104
105
106
107
108
109
# File 'lib/insika/refinement_store.rb', line 100

def complete(id, findings:, excluded: 0)
  update(id) do |record|
    guard_open!(record)
    list = Array(findings).map { |f| deep_stringify(f.respond_to?(:to_h) ? f.to_h : f) }
    record["findings"] = list
    record["excluded"] = Integer(excluded)
    record["status"] = list.empty? ? "no_findings" : "completed"
    record["finished_at"] = timestamp
  end
end

#create(agent_id:, window: {}, id: SecureRandom.uuid, at: nil) ⇒ Object

Opens a run (:collecting). window is the collector's window as data ({ "last_sessions" => N } | { "since" => iso8601 }) — recorded so a report can be read months later and still say what it looked at. -> Run.



78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
# File 'lib/insika/refinement_store.rb', line 78

def create(agent_id:, window: {}, id: SecureRandom.uuid, at: nil)
  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 = at || timestamp
  record = {
    "id" => id.to_s, "agent_id" => agent, "status" => "collecting",
    "window" => deep_stringify(window || {}), "findings" => [], "excluded" => 0,
    "started_at" => started, "finished_at" => nil, "error" => nil,
    "candidate" => nil, "candidates" => [], "gate" => nil, "cost" => nil,
    "decision" => nil
  }
  @store.set(SCOPE, key_for(agent, started, id), record)
  to_run(record)
end

#fail(id, error:) ⇒ Object

Closes a run as :failed, recording the error. -> Run.



112
113
114
115
116
117
118
119
# File 'lib/insika/refinement_store.rb', line 112

def fail(id, error:)
  update(id) do |record|
    guard_open!(record)
    record["status"] = "failed"
    record["error"] = error.to_s
    record["finished_at"] = timestamp
  end
end

#find(id) ⇒ Object

-> Run | nil. O(n) scan over the scope (the key carries agent+timestamp, so there is no index by id): one node, local SQLite, runs are operator-paced.



207
208
209
210
# File 'lib/insika/refinement_store.rb', line 207

def find(id)
  key = key_for_id(id)
  key && to_run(@store.get(SCOPE, key))
end

#for_agent(agent_id, limit: nil) ⇒ Object

-> [Run] for one agent, MOST RECENT FIRST, capped by limit.



213
214
215
216
217
# File 'lib/insika/refinement_store.rb', line 213

def for_agent(agent_id, limit: nil)
  keys = @store.list(SCOPE, "#{KEY_PREFIX}#{agent_id}:").reverse
  keys = keys.first(limit) if limit
  keys.filter_map { |k| to_run(@store.get(SCOPE, k)) }
end

#gated(id, report:, panel: nil, cost: nil) ⇒ Object

Records the gate's verdict. A PASS parks the run at :awaiting_approval — a human still has to say yes, which is the product and not a formality (D2). A FAIL is terminal as :rejected, with the gate report as the stated reason: the same finding must re-surface with new evidence before anything is proposed again, so there is no silent retry loop (§3.6).

report is the WINNER's (or, when nothing survived, the most informative refusal). panel is every scored entry — the store attaches it as-is and picks the winning candidate out of it by id. Which candidate WON is the caller's ranking decision (§3.5); this store does not rank, it records. -> Run.



157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
# File 'lib/insika/refinement_store.rb', line 157

def gated(id, report:, panel: nil, cost: nil)
  update(id) do |record|
    unless record["status"] == "gating"
      raise ArgumentError, "run #{record['id']} is #{record['status']}, expected gating"
    end

    gate = deep_stringify(report.respond_to?(:to_h) ? report.to_h : report)
    record["candidates"] = panel.map { |e| entry_for(e) } if panel
    record["candidate"] = winning_candidate(record, gate)
    record["gate"] = gate
    record["cost"] = deep_stringify(cost.respond_to?(:to_h) ? cost.to_h : cost) if cost
    if gate["passed"]
      record["status"] = "awaiting_approval"
    else
      record["status"] = "rejected"
      record["decision"] = { "by" => "gate", "at" => timestamp, "note" => gate["reason"] }
      record["finished_at"] = timestamp
    end
  end
end

#gating(id, candidate: nil, candidates: nil) ⇒ Object

Attaches the candidate(s) under gate and moves the run to :gating. Only a completed run can be gated: a report with no findings has nothing to propose from, and a failed one never finished looking.

candidate: (one) and candidates: (a panel) are the same call — the panel is recorded before the gate runs so the Studio can show WHAT is being scored while it is being scored, which is minutes of real replay. No winner is claimed yet: candidate stays nil until gated says which one it is. -> Run.



131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
# File 'lib/insika/refinement_store.rb', line 131

def gating(id, candidate: nil, candidates: nil)
  panel = Array(candidates || [candidate].compact)
  raise Insika::ValidationError, "a candidate is required to gate" if panel.empty?

  update(id) do |record|
    unless record["status"] == "completed"
      raise ArgumentError, "run #{record['id']} is #{record['status']}, expected completed"
    end

    record["candidates"] = panel.map { |c| entry_for(c) }
    record["candidate"] = nil
    record["gate"] = nil
    record["status"] = "gating"
  end
end

#latest_for(agent_id) ⇒ Object

-> Run | nil (the agent's most recent run, whatever its status).



220
# File 'lib/insika/refinement_store.rb', line 220

def latest_for(agent_id) = for_agent(agent_id, limit: 1).first

#recent(limit: 20) ⇒ Object

-> [Run] across every agent, most recent first, capped.



223
224
225
226
227
# File 'lib/insika/refinement_store.rb', line 223

def recent(limit: 20)
  @store.list(SCOPE, KEY_PREFIX)
        .filter_map { |k| to_run(@store.get(SCOPE, k)) }
        .sort_by { |r| r.started_at.to_s }.reverse.first(limit)
end

#resolve(id, decision:, operator: nil, note: nil) ⇒ Object

The operator's answer to a gated proposal. applied is recorded only after the writes land, so a crash between the two leaves the run awaiting approval and the operator re-approves — replaying a write that is already versioned and idempotent-ish beats recording a lie. -> Run.



182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
# File 'lib/insika/refinement_store.rb', line 182

def resolve(id, decision:, operator: nil, note: nil)
  target = decision.to_sym
  unless %i[applied rejected].include?(target)
    raise Insika::ValidationError, "invalid decision: #{decision} (applied|rejected)"
  end

  update(id) do |record|
    unless record["status"] == "awaiting_approval"
      raise ArgumentError, "run #{record['id']} is #{record['status']}, expected awaiting_approval"
    end

    record["status"] = target.to_s
    record["decision"] = { "by" => (Coercion.presence(operator) || "operator").to_s,
                           "at" => timestamp, "note" => Coercion.presence(note) }.compact
    record["finished_at"] = timestamp
  end
end