wrangle 0.1.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.
@@ -0,0 +1,298 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "errors"
4
+
5
+ module Wrangle
6
+ # Runs a goal, or a plan of goals, to a stopping point.
7
+ #
8
+ # This is the only part of Wrangle that acts without being told to, so it is all policy: when a
9
+ # decision may be executed, when a page deserves another look, and when the run has to hand back.
10
+ # It never touches Safari itself — the session it is given owns that, and owns the page — which
11
+ # keeps "what may happen" separate from "how it happens".
12
+ #
13
+ # The rules here were each written after a live page broke the version without them, and the
14
+ # comments say which, because the reason is the only thing that makes them worth keeping.
15
+ class RunLoop
16
+ # Acting on a 5%-confidence target is how an early build typed the origin into Google's
17
+ # multi-city field. Below the floor the run stops and says what it was torn between.
18
+ DEFAULT_CONFIDENCE = 0.5
19
+ # Settling starts impatient: a long poll on every step is what makes a run feel like it is
20
+ # stalling, and a page that streams content in never goes still anyway. The first attempt does
21
+ # not wait at all — the settle now runs underneath the Jev request, where it is free.
22
+ STEADY_BUDGET = 0.2
23
+ STEADY_CEILING = 1.2
24
+ MAX_MISSES = 4
25
+ # A page half-rendered when the model looked reads as ambiguity. A second decision costs ~350ms
26
+ # and a handoff to the calling agent costs a full model turn, measured at 5-6s.
27
+ SOFT_SETTLE = 0.6
28
+ MAX_SOFT = 1
29
+ SPIN_ALLOWANCE = 3
30
+ RECONSIDER = %i[unsure soft_done absent].freeze
31
+ DEFAULT_LEG_STEPS = 8
32
+ # Acting wrongly costs one action, which the next step can usually undo. Declaring BLOCKED throws
33
+ # away every remaining leg, and no later step can recover it.
34
+ BLOCKED_FLOOR = 0.6
35
+ BLOCKED_RELOOKS = 3
36
+ # Settling a churning page and waiting for a control to arrive are different kinds of patience.
37
+ # Amazon's filter sidebar has taken over two seconds after its results were already interactive.
38
+ BLOCKED_CEILING = 2.5
39
+ # A confident disagreement from the verification head outweighs the claim; an unsure one is noise.
40
+ VERIFY_FLOOR = 0.6
41
+ # A claim is put back to the page this many times before the loop stops arguing with it. A leg
42
+ # that has acted is only arguing about whether its own work shows, so one more look settles it; a
43
+ # leg that has done nothing is the premature-DONE case, and that one is worth pressing.
44
+ CLAIM_LOOKS = { acted: 2, idle: 3 }.freeze
45
+
46
+ def initialize(session, request, expect)
47
+ @session = session
48
+ @request = request
49
+ @expect = expect
50
+ end
51
+
52
+ # One CLI call, several sub-goals. Each leg runs to its own DONE and the next begins, so the
53
+ # caller is not paid a full model turn for every handoff — on a form that was ten round trips to
54
+ # the calling agent, which dwarfed the decisions themselves. A leg that does not reach DONE ends
55
+ # the plan: the later legs assume the earlier ones happened, so guessing past a failure is how a
56
+ # run types a date into a passenger field.
57
+ def run(plan)
58
+ budget = (@request["steps"] || 20).to_i
59
+ leg_cap = (@request["leg_steps"] || DEFAULT_LEG_STEPS).to_i
60
+ steps = []
61
+ plan.each_with_index do |goal, index|
62
+ remaining = budget - steps.count { |step| step["operation"] != "GOAL" }
63
+ break if remaining <= 0
64
+
65
+ steps << goal_step(goal, index) if plan.size > 1
66
+ leg = leg(@request.merge("goal" => goal), [remaining, leg_cap].min)
67
+ steps.concat(leg)
68
+ break unless leg.last&.fetch("operation", nil) == "DONE"
69
+ end
70
+ steps
71
+ end
72
+
73
+ private
74
+
75
+ def goal_step(goal, index) = { "operation" => "GOAL", "action" => goal, "confidence" => 0.0, "index" => index + 1 }
76
+
77
+ # The budget counts work done, not attempts made. A stale retry, a second look at a half-rendered
78
+ # page, or a claim that did not check out is overhead, and charging it to the leg means a form
79
+ # that churns a little runs out of allowance before it finishes — while a separate spin cap still
80
+ # stops a loop that is making no progress at all.
81
+ def leg(request, budget)
82
+ steps = []
83
+ tally = { missed: 0, soft: 0, done: 0, claims: 0 }
84
+ spins = 0
85
+ while tally[:done] < budget && spins < budget * SPIN_ALLOWANCE
86
+ spins += 1
87
+ outcome = attempt(request, steps, tally)
88
+ break if outcome == :stale && tally[:missed] >= MAX_MISSES
89
+ next if outcome == :stale
90
+
91
+ look = reconsider(outcome, steps, tally)
92
+ next if look == :again
93
+ break if look == :spent
94
+
95
+ # A disputed DONE is not work and does not spend the budget. The leg carries on and looks
96
+ # again, which is what it would have done had it never claimed to be finished — but not
97
+ # instantly: "the page has not got there yet" is the commonest true reason for a dispute, and
98
+ # asking again in the same breath gets the same answer. Amazon's results were mid-load for
99
+ # both claims, and a search that had plainly worked was handed back.
100
+ if outcome == :unproven
101
+ @session.steady(patience(:absent, tally[:claims] - 1))
102
+ next
103
+ end
104
+ break if outcome == :stop
105
+
106
+ tally.merge!(soft: 0, missed: 0, done: tally[:done] + 1)
107
+ break if @expect && @session.page["text"].match?(@expect)
108
+ break if @session.stalled?
109
+ end
110
+ steps
111
+ end
112
+
113
+ # Spend the cheap look before the expensive handoff, then stop asking.
114
+ def reconsider(outcome, steps, tally)
115
+ return nil unless RECONSIDER.include?(outcome)
116
+ return :spent if tally[:soft] >= (outcome == :absent ? BLOCKED_RELOOKS : MAX_SOFT)
117
+
118
+ waited = patience(outcome, tally[:soft])
119
+ tally[:soft] += 1
120
+ began = now
121
+ @session.steady(waited)
122
+ steps.push(looked_again(steps.pop, tally[:soft], ((now - began) * 1000).round))
123
+ :again
124
+ end
125
+
126
+ # A stale page means the decision was made about a page that no longer exists. The freshness
127
+ # check fires before any input, so nothing was delivered and deciding again is safe. This is the
128
+ # one retry Wrangle allows itself, and only because no mutation happened.
129
+ def attempt(request, steps, tally)
130
+ began = now
131
+ # When the page proves it is churning faster than a decision can be made, back off instead of
132
+ # spinning — an animating menu will invalidate the target forever at a fixed retry rate.
133
+ @session.steady(backoff(request["steady"].to_f, tally[:missed]))
134
+ settled = now
135
+ step = @session.decide(request)
136
+ steps << step.except("choice").merge("settle_ms" => ((settled - began) * 1000).round)
137
+ advance(step, request, steps.last, began, tally)
138
+ rescue StalePage => e
139
+ tally[:missed] += 1
140
+ steps << missed_step(tally[:missed], began, e)
141
+ :stale
142
+ end
143
+
144
+ def advance(step, request, record, began, tally)
145
+ choice = step.fetch("choice")
146
+ return stopping(choice, request, record, tally) if choice.stop?
147
+ return :unsure if unsure?(choice, request, record)
148
+
149
+ text, wanted = text_for(choice, request)
150
+ if wanted
151
+ record.merge!("operation" => "HANDOFF", "action" => wanted)
152
+ return :stop
153
+ end
154
+ record.merge!(@session.perform(choice, text).merge("step_ms" => ((now - began) * 1000).round))
155
+ :go
156
+ end
157
+
158
+ # DONE and BLOCKED are not symmetric. Inside a plan an uncertain DONE is cheap to be wrong about —
159
+ # the next leg simply does the work that was not done — while an uncertain BLOCKED abandons every
160
+ # remaining leg. So look again at both, then let DONE through and make BLOCKED earn a handoff.
161
+ def stopping(choice, request, record, tally)
162
+ return :absent if unconfirmed_blocked?(choice, request, record, tally)
163
+
164
+ disputed = disputed_done(choice, request, record, tally)
165
+ return disputed if disputed
166
+ return :stop unless weak?(choice, request)
167
+ return :absent if choice.operation == "BLOCKED" && unsure?(choice, request, record)
168
+
169
+ :soft_done
170
+ end
171
+
172
+ # DONE is the model reporting on its own work, chosen from the same look that proposed the
173
+ # actions, and it is optimistic: an Amazon plan reported success with the filter it had been
174
+ # asked for never applied. The verification head asked alongside it has no action to gain by
175
+ # saying yes, so a confident disagreement is worth more than the claim.
176
+ #
177
+ # But it is worth more, not final, and the two ways of being wrong do not cost the same. The
178
+ # verifier reads one snapshot; the actor knows what it did. A leg that applied an Amazon filter
179
+ # was disputed at 80% five runs in a row, because the only proof on the page was one link
180
+ # offering to remove the filter, among a hundred other elements — and handing back there throws
181
+ # away every remaining leg to win an argument the page cannot settle.
182
+ #
183
+ # So the dispute is decisive only against a leg that has not done anything, which is the case it
184
+ # was built for. A leg that acted is taken at its word once it has looked again, and the doubt is
185
+ # written into the transcript for whoever reads it.
186
+ def disputed_done(choice, request, record, tally)
187
+ return nil unless choice.operation == "DONE" && choice.disputed?(VERIFY_FLOOR)
188
+
189
+ tally[:claims] += 1
190
+ idle = tally[:done].zero?
191
+ said = "the page does not show #{request["goal"].to_s.inspect} (#{pct(choice.met_confidence)} sure)"
192
+ return unproven(record, choice, said) if tally[:claims] < CLAIM_LOOKS[idle ? :idle : :acted]
193
+ return handoff(record, choice, said, tally) if idle
194
+
195
+ record["action"] = "Done, but #{said}; taking the work at its word"
196
+ nil
197
+ end
198
+
199
+ def unproven(record, choice, said)
200
+ record.merge!("operation" => "UNPROVEN", "confidence" => choice.met_confidence,
201
+ "action" => "Said done, but #{said}; carrying on")
202
+ :unproven
203
+ end
204
+
205
+ def handoff(record, choice, said, tally)
206
+ record.merge!("operation" => "HANDOFF", "confidence" => choice.met_confidence,
207
+ "action" => "Said done #{tally[:claims]} times without doing anything, " \
208
+ "but #{said}; check it yourself")
209
+ :stop
210
+ end
211
+
212
+ # A leg begins the instant the one before it ends, and the action that ended it may have started
213
+ # a navigation. So the first decisions of a leg are looking at the previous page as often as not,
214
+ # and "the control is not here" is exactly what a half-loaded document looks like.
215
+ #
216
+ # Confidence cannot gate this. Looking again at a page whose sidebar still has not arrived makes
217
+ # the model surer of the absence, not less — 43% then 76%, both wrong. What makes a BLOCKED cheap
218
+ # to disbelieve is that the leg has not done anything yet: there is nothing to undo, and nothing
219
+ # to lose but the wait.
220
+ def fresh_leg?(tally) = tally[:done].zero? && tally[:soft].to_i < BLOCKED_RELOOKS
221
+
222
+ def unconfirmed_blocked?(choice, request, record, tally)
223
+ return false unless choice.operation == "BLOCKED" && fresh_leg?(tally)
224
+
225
+ unsure?(choice, request, record, force: true)
226
+ end
227
+
228
+ def unsure?(choice, request, record, force: false)
229
+ return false unless force || weak?(choice, request)
230
+
231
+ weakest = confidence(choice)
232
+ reason = if force && !weak?(choice, request)
233
+ "#{choice.label.inspect} on a leg that has not acted yet (#{pct(weakest)} sure)"
234
+ else
235
+ "Not sure enough to act (#{pct(weakest)} on #{choice.label.inspect})"
236
+ end
237
+ record.merge!("operation" => "HANDOFF", "confidence" => weakest,
238
+ "action" => "#{reason}; look at the page and choose")
239
+ true
240
+ end
241
+
242
+ # Jev chooses; it never writes. The value comes from the caller, and when the caller has not
243
+ # supplied one the run stops and asks. The agent driving Wrangle is already a language model, so
244
+ # the "small model that types" is whoever is reading this output — no second key, no extra hop.
245
+ def text_for(choice, request)
246
+ return [nil, nil] unless choice.action["kind"] == "fill"
247
+
248
+ label = choice.label.downcase
249
+ _, literal = (request["literals"] || {}).find { |key, _| label.include?(key.to_s.downcase) }
250
+ return [literal, nil] if literal
251
+
252
+ [nil, "needs text for #{choice.label.inspect}; re-run with --literal #{label.split(/[^a-z]/).first}=VALUE"]
253
+ end
254
+
255
+ # A second look is not free and it is not nothing: it explains both the pause and why the run
256
+ # ended up where it did, so it belongs in the transcript rather than being quietly discarded.
257
+ # The pause is reported as the time actually spent, not the time allowed — a page that goes still
258
+ # early cuts it short, and a transcript that quoted the budget would overstate every one of them.
259
+ def looked_again(step, look, waited)
260
+ { "operation" => "RELOOK", "confidence" => step["confidence"], "step_ms" => waited,
261
+ "action" => "#{step["operation"] == "HANDOFF" ? step["action"][/\A[^;]+/] : "Not sure yet"}; " \
262
+ "waited #{waited}ms and looked again (#{look})" }
263
+ end
264
+
265
+ # Which kind of staleness this was is the whole diagnostic value of the step: a guard that moved,
266
+ # a document that was replaced, and a target that went behind an overlay are three different
267
+ # problems, and a transcript that calls them all "the page moved" hides which one is costing the
268
+ # run its time.
269
+ def missed_step(missed, began, error)
270
+ @session.observe!
271
+ { "operation" => "RESTALE", "confidence" => 0.0, "step_ms" => ((now - began) * 1000).round,
272
+ "reason" => error.message.sub(/\.?\s*Observe again\.?\z/, ""),
273
+ "action" => "The page moved while deciding; looked again (#{missed})" }
274
+ end
275
+
276
+ def floor_for(choice, request)
277
+ base = (request["min_confidence"] || DEFAULT_CONFIDENCE).to_f
278
+ choice.operation == "BLOCKED" ? [base, BLOCKED_FLOOR].max : base
279
+ end
280
+
281
+ def weak?(choice, request) = confidence(choice) < floor_for(choice, request)
282
+ def confidence(choice) = [choice.confidence, choice.target_confidence].compact.min.to_f
283
+
284
+ # A retry is the page saying it is churning faster than a decision can be made, so back off
285
+ # rather than spinning: an animating menu or a calendar streaming its prices in will invalidate
286
+ # the target forever at a fixed retry rate. The first attempt waits only if the caller asked it
287
+ # to, because the watch during the request has already done the settling for free.
288
+ def backoff(base, missed)
289
+ return base if missed.zero?
290
+
291
+ [[base, STEADY_BUDGET].max * (2**missed), STEADY_CEILING].min
292
+ end
293
+
294
+ def patience(outcome, soft) = [SOFT_SETTLE * (2**soft), outcome == :absent ? BLOCKED_CEILING : STEADY_CEILING].min
295
+ def pct(value) = "#{(value.to_f * 100).round}%"
296
+ def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
297
+ end
298
+ end