@rulemetric/local 0.10.0 → 0.12.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.
- package/dist/meta.json +2 -2
- package/dist/server.mjs +682 -496
- package/dist/supabase/migrations/00087_cleanup_test_data_fix.sql +1 -1
- package/dist/supabase/migrations/00184_auto_accept_budget_settings.sql +113 -0
- package/dist/supabase/migrations/00185_trial_throughput_settings.sql +43 -0
- package/dist/supabase/migrations/00186_canonical_checkout_prefers_live.sql +256 -0
- package/dist/supabase/migrations/00187_recommendation_applications.sql +32 -0
- package/dist/supabase/migrations/00188_training_producer_canonical_path.sql +117 -0
- package/dist/supabase/migrations/00189_limit_obs_window_index.sql +37 -0
- package/dist/supabase/migrations/00190_eval_targets_artifact_link.sql +176 -0
- package/dist/supabase/migrations/00191_recommendation_applications_effect_ledger.sql +106 -0
- package/dist/supabase/migrations/00192_cleanup_reaps_memberless_orgs.sql +142 -0
- package/dist/supabase/migrations/00193_judge_segment_pin_and_anchors.sql +367 -0
- package/dist/supabase/migrations/00194_refine_cases_producer.sql +91 -0
- package/dist/supabase/migrations/00195_skill_usage_views.sql +109 -0
- package/dist/supabase/migrations/00196_retire_unused_skills_producer.sql +102 -0
- package/dist/supabase/migrations/00197_auto_accept_producer_admits_skills.sql +115 -0
- package/dist/supabase/migrations/00198_unstale_non_catalog_suggestions.sql +51 -0
- package/dist/supabase/migrations/00199_auto_accept_runs_every_two_hours.sql +54 -0
- package/dist/supabase/migrations/00200_auto_accept_runs_hourly.sql +42 -0
- package/dist/web/assets/{docs-D_fJ3Svo.js → docs-KIeJWTb-.js} +35 -48
- package/dist/web/assets/index-CFJatBZ3.css +1 -0
- package/dist/web/assets/{index-D8PcENaI.js → index-MYof2fnF.js} +59 -59
- package/dist/web/index.html +3 -3
- package/package.json +2 -2
- package/dist/web/assets/index-B2QaicI_.css +0 -1
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
-- The judge segment gets ONE definition, and a pin outranks anything observed.
|
|
2
|
+
--
|
|
3
|
+
-- §6c scopes calibration to the current judge, and until now "current judge"
|
|
4
|
+
-- was hand-copied into four places (00148, 00169, 00190, judge-calibration.ts)
|
|
5
|
+
-- as "the most recently stamped run". Recency asks who graded LAST when the
|
|
6
|
+
-- question is whose bias we can CORRECT, and the two come apart the instant a
|
|
7
|
+
-- stray run lands. Measured 2026-08-18 on the one evolve-enrolled target:
|
|
8
|
+
--
|
|
9
|
+
-- claude-opus-5[1m] 355 runs Jul 30 - Aug 13
|
|
10
|
+
-- claude-opus-4-8[1m] 303 runs Jul 20 - Jul 29 <- all 37 labels
|
|
11
|
+
-- (unstamped) 159 runs Mar 25 - Aug 17
|
|
12
|
+
-- claude-fable-5 113 runs Aug 08 - Aug 14
|
|
13
|
+
-- claude-haiku-4-5 80 runs Aug 14 - Aug 16 <- elected, 0 labels
|
|
14
|
+
--
|
|
15
|
+
-- Recency elected the segment with zero anchors, so the adequacy gate reported
|
|
16
|
+
-- `0/50 human-labeled runs` and asked for fifty more while thirty-seven sat one
|
|
17
|
+
-- segment away. 00148's grader pin then pinned the NEXT batch to that same
|
|
18
|
+
-- stray, making the drift self-reinforcing; #357 quarantined fallback runs from
|
|
19
|
+
-- electing, but a run the producer never pinned has nothing to fall back FROM,
|
|
20
|
+
-- so unpinned evolution batches walked straight through the quarantine.
|
|
21
|
+
--
|
|
22
|
+
-- Three tiers, descending authority:
|
|
23
|
+
-- 1. PIN metadata.graderModel — a decision a person made. Nothing
|
|
24
|
+
-- observed in the run table may overrule it. A pinned segment
|
|
25
|
+
-- with zero runs is a correct answer: grading starts there.
|
|
26
|
+
-- 2. ANCHORS the segment holding the most fully human-labeled runs — the
|
|
27
|
+
-- only segment that can produce a corrected estimate at all.
|
|
28
|
+
-- 3. RECENCY the old rule, surviving as the tie-break and the
|
|
29
|
+
-- no-anchors-anywhere fallback.
|
|
30
|
+
--
|
|
31
|
+
-- Fallback runs (stamped != requested) are quarantined from ELECTING but still
|
|
32
|
+
-- count as data, exactly as #357 established. If every stamped run fell back,
|
|
33
|
+
-- the whole stamped set votes rather than electing nothing — an empty segment
|
|
34
|
+
-- would silently drop every LLM-graded run.
|
|
35
|
+
--
|
|
36
|
+
-- apps/api/src/lib/judge-calibration.ts `electJudgeSegment()` implements the
|
|
37
|
+
-- same three tiers for the TypeScript readers, and its unit tests pin the tier
|
|
38
|
+
-- order. Change one side without the other and the scheduled path will measure
|
|
39
|
+
-- a different segment than the reviewed path.
|
|
40
|
+
-- ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
create or replace function public.eval_target_judge_segment(p_target_id uuid)
|
|
43
|
+
returns text
|
|
44
|
+
language sql
|
|
45
|
+
stable
|
|
46
|
+
security definer
|
|
47
|
+
set search_path = public
|
|
48
|
+
as $$
|
|
49
|
+
with elector as (
|
|
50
|
+
select
|
|
51
|
+
r.grading->>'judge_model' as model,
|
|
52
|
+
r.created_at,
|
|
53
|
+
(
|
|
54
|
+
r.grading->>'judge_model_requested' is null
|
|
55
|
+
or r.grading->>'judge_model_requested' = r.grading->>'judge_model'
|
|
56
|
+
) as clean,
|
|
57
|
+
-- Guarded: a grading blob whose expectations is not an array would make
|
|
58
|
+
-- jsonb_array_elements raise, and one malformed run must not take the
|
|
59
|
+
-- whole election down.
|
|
60
|
+
case
|
|
61
|
+
when jsonb_typeof(r.grading->'expectations') = 'array' then (
|
|
62
|
+
select count(*)
|
|
63
|
+
from jsonb_array_elements(r.grading->'expectations') e
|
|
64
|
+
where coalesce(e->>'method', 'llm') = 'llm'
|
|
65
|
+
and coalesce(e->>'errored', 'false') <> 'true'
|
|
66
|
+
)
|
|
67
|
+
else 0
|
|
68
|
+
end as llm_total,
|
|
69
|
+
coalesce(a.llm_annotated, 0) as llm_annotated
|
|
70
|
+
from eval_runs r
|
|
71
|
+
left join (
|
|
72
|
+
select eval_run_id, count(*) filter (where judge_method = 'llm') as llm_annotated
|
|
73
|
+
from eval_grade_annotations
|
|
74
|
+
where eval_target_id = p_target_id
|
|
75
|
+
group by eval_run_id
|
|
76
|
+
) a on a.eval_run_id = r.id
|
|
77
|
+
where r.eval_target_id = p_target_id
|
|
78
|
+
and r.status = 'completed'
|
|
79
|
+
and r.configuration in ('with_target', 'without_target')
|
|
80
|
+
and r.grading->>'judge_model' is not null
|
|
81
|
+
),
|
|
82
|
+
has_clean as (
|
|
83
|
+
select exists (select 1 from elector where clean) as v
|
|
84
|
+
),
|
|
85
|
+
electorate as (
|
|
86
|
+
select e.* from elector e, has_clean h where e.clean or not h.v
|
|
87
|
+
),
|
|
88
|
+
pin as (
|
|
89
|
+
select nullif(btrim(t.metadata->>'graderModel'), '') as model
|
|
90
|
+
from eval_targets t
|
|
91
|
+
where t.id = p_target_id
|
|
92
|
+
),
|
|
93
|
+
anchors as (
|
|
94
|
+
-- Ties broken by recency then model string: a tie-break that fell through
|
|
95
|
+
-- to nothing would leave the segment depending on row order, which is the
|
|
96
|
+
-- same instability recency had.
|
|
97
|
+
select model
|
|
98
|
+
from electorate
|
|
99
|
+
where llm_total > 0 and llm_annotated >= llm_total
|
|
100
|
+
group by model
|
|
101
|
+
order by count(*) desc, max(created_at) desc, model desc
|
|
102
|
+
limit 1
|
|
103
|
+
),
|
|
104
|
+
recency as (
|
|
105
|
+
select model from electorate order by created_at desc, model desc limit 1
|
|
106
|
+
)
|
|
107
|
+
select coalesce(
|
|
108
|
+
(select model from pin),
|
|
109
|
+
(select model from anchors),
|
|
110
|
+
(select model from recency)
|
|
111
|
+
);
|
|
112
|
+
$$;
|
|
113
|
+
|
|
114
|
+
comment on function public.eval_target_judge_segment(uuid) is
|
|
115
|
+
'Canonical §6c judge segment for an eval target: metadata.graderModel pin, else the segment holding the most fully human-labeled runs, else the most recently stamped run. Fallback-graded runs (stamped <> requested) cannot elect. Mirrors electJudgeSegment() in apps/api/src/lib/judge-calibration.ts — the two must not diverge. Never re-derive this inline: the hand-copied recency rule lived in four places and elected a zero-anchor segment for four days (2026-08-18).';
|
|
116
|
+
|
|
117
|
+
-- ---------------------------------------------------------------------------
|
|
118
|
+
-- Both producers now ask the function instead of carrying their own copy.
|
|
119
|
+
-- ---------------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
create or replace function public.enqueue_eval_autorun()
|
|
122
|
+
returns integer
|
|
123
|
+
language plpgsql
|
|
124
|
+
security definer
|
|
125
|
+
set search_path = public
|
|
126
|
+
as $$
|
|
127
|
+
declare
|
|
128
|
+
queued integer;
|
|
129
|
+
begin
|
|
130
|
+
with eligible as (
|
|
131
|
+
select t.id, t.user_id,
|
|
132
|
+
-- Canonical segment (00193). Was an inline "most recent stamped run"
|
|
133
|
+
-- subquery, which is how a credit-exhaustion fallback became the pin.
|
|
134
|
+
public.eval_target_judge_segment(t.id) as current_judge
|
|
135
|
+
from eval_targets t
|
|
136
|
+
where coalesce(t.metadata->>'autoRunEnabled', 'false') = 'true'
|
|
137
|
+
-- Gate 0 (00190): the target must be anchored to something a promotion
|
|
138
|
+
-- could land on. Without this, a batch is two LLM executions plus a
|
|
139
|
+
-- judged grade spent to rewrite a row nothing reads.
|
|
140
|
+
and (
|
|
141
|
+
t.instruction_id is not null
|
|
142
|
+
or t.project_path is not null
|
|
143
|
+
or t.name like '/%'
|
|
144
|
+
)
|
|
145
|
+
-- Gate 2a: recent grading wasn't MOSTLY ungradeable (proportional, and
|
|
146
|
+
-- identical to the evolution producer's 2a since 00163).
|
|
147
|
+
and not exists (
|
|
148
|
+
select 1 from eval_runs r
|
|
149
|
+
where r.eval_target_id = t.id
|
|
150
|
+
and r.created_at > now() - interval '7 days'
|
|
151
|
+
group by r.eval_target_id
|
|
152
|
+
having count(*) >= 5
|
|
153
|
+
and count(*) filter (
|
|
154
|
+
where jsonb_typeof(r.grading->'expectations') = 'array'
|
|
155
|
+
and exists (
|
|
156
|
+
select 1 from jsonb_array_elements(r.grading->'expectations') e
|
|
157
|
+
where coalesce(e->>'errored', 'false') = 'true'
|
|
158
|
+
)
|
|
159
|
+
) * 4 >= count(*)
|
|
160
|
+
)
|
|
161
|
+
-- Gate 2b: no human anchors pointing at an ungradeable assertion.
|
|
162
|
+
and not exists (
|
|
163
|
+
select 1
|
|
164
|
+
from eval_grade_annotations a
|
|
165
|
+
join eval_runs r on r.id = a.eval_run_id
|
|
166
|
+
where a.eval_target_id = t.id
|
|
167
|
+
and jsonb_typeof(r.grading->'expectations') = 'array'
|
|
168
|
+
and coalesce(
|
|
169
|
+
r.grading->'expectations'->(a.expectation_index)->>'errored', 'false'
|
|
170
|
+
) = 'true'
|
|
171
|
+
)
|
|
172
|
+
-- Gate 2c: last week's batch wasn't mostly failures.
|
|
173
|
+
and not exists (
|
|
174
|
+
select 1 from eval_runs r
|
|
175
|
+
where r.eval_target_id = t.id
|
|
176
|
+
and r.created_at > now() - interval '7 days'
|
|
177
|
+
group by r.eval_target_id
|
|
178
|
+
having count(*) >= 5
|
|
179
|
+
and count(*) filter (where r.status = 'failed') * 4 >= count(*)
|
|
180
|
+
)
|
|
181
|
+
-- Gate 3: the target has live cases to actually run.
|
|
182
|
+
and exists (
|
|
183
|
+
select 1 from evals ev
|
|
184
|
+
where ev.eval_target_id = t.id and ev.retired_at is null
|
|
185
|
+
)
|
|
186
|
+
-- One in flight at a time, per the 00083/00086 pattern.
|
|
187
|
+
and not exists (
|
|
188
|
+
select 1 from agent_jobs j
|
|
189
|
+
where j.task_kind = 'cron_eval_autorun'
|
|
190
|
+
and j.status in ('pending', 'claimed', 'running')
|
|
191
|
+
and j.payload->>'evalTargetId' = t.id::text
|
|
192
|
+
)
|
|
193
|
+
), ins as (
|
|
194
|
+
insert into agent_jobs (user_id, task_kind, status, payload, dedupe_key)
|
|
195
|
+
select e.user_id,
|
|
196
|
+
'cron_eval_autorun',
|
|
197
|
+
'pending',
|
|
198
|
+
jsonb_build_object('evalTargetId', e.id::text, 'maxRuns', 12)
|
|
199
|
+
|| case
|
|
200
|
+
when e.current_judge is not null
|
|
201
|
+
then jsonb_build_object('graderModel', e.current_judge)
|
|
202
|
+
else '{}'::jsonb
|
|
203
|
+
end,
|
|
204
|
+
'eval-autorun:' || e.id::text
|
|
205
|
+
from eligible e
|
|
206
|
+
returning 1
|
|
207
|
+
)
|
|
208
|
+
select count(*)::int into queued from ins;
|
|
209
|
+
|
|
210
|
+
return queued;
|
|
211
|
+
end;
|
|
212
|
+
$$;
|
|
213
|
+
|
|
214
|
+
comment on function public.enqueue_eval_autorun() is
|
|
215
|
+
'Nightly producer for cron_eval_autorun. Enqueues one bounded depth batch per OPTED-IN, ANCHORED, healthy, under-powered eval target, with the grader pinned to the target''s canonical judge segment (public.eval_target_judge_segment, 00193 — pin > anchors > recency; was an inline recency subquery from 00148/#196). Gate 0 (00190) requires instruction_id, project_path, or an absolute-path name. Health 2a is PROPORTIONAL as of 00169. Opt in with: update eval_targets set metadata = coalesce(metadata, ''{}''::jsonb) || ''{"autoRunEnabled":true}''::jsonb where id = ...';
|
|
216
|
+
|
|
217
|
+
-- ---------------------------------------------------------------------------
|
|
218
|
+
-- The evolution producer never pinned a grader AT ALL.
|
|
219
|
+
--
|
|
220
|
+
-- 00148 pinned the autorun batch precisely so a scheduled run could not fork
|
|
221
|
+
-- the segment. The evolution producer — which spends far more per night, and
|
|
222
|
+
-- whose paired replays are the ONLY route to a promotion (§13, Mode A) — was
|
|
223
|
+
-- never given the same treatment. Its runs therefore carried no
|
|
224
|
+
-- `judge_model_requested`, so #357's fallback quarantine had nothing to compare
|
|
225
|
+
-- against and they elected freely. Unpinned evolution batches are how
|
|
226
|
+
-- claude-haiku-4-5 took the segment on 2026-08-14.
|
|
227
|
+
--
|
|
228
|
+
-- Function is otherwise byte-identical to 00183.
|
|
229
|
+
-- ---------------------------------------------------------------------------
|
|
230
|
+
|
|
231
|
+
create or replace function public.enqueue_instruction_evolution()
|
|
232
|
+
returns integer
|
|
233
|
+
language plpgsql
|
|
234
|
+
security definer
|
|
235
|
+
set search_path = public
|
|
236
|
+
as $$
|
|
237
|
+
declare
|
|
238
|
+
queued integer;
|
|
239
|
+
begin
|
|
240
|
+
with health_ok as (
|
|
241
|
+
-- The gates SHARED by both enrolment paths, evaluated once. All copied
|
|
242
|
+
-- byte-identical from the live definition (00163's revision): pause,
|
|
243
|
+
-- proportional 2a, absolute 2b, proportional 2c, live cases, change
|
|
244
|
+
-- budget, one-in-flight.
|
|
245
|
+
select t.id, t.user_id
|
|
246
|
+
from eval_targets t
|
|
247
|
+
where coalesce(t.metadata->>'autoEvolvePaused', 'false') <> 'true'
|
|
248
|
+
-- Health 2a: recent grading wasn't MOSTLY ungradeable (proportional, 00163).
|
|
249
|
+
and not exists (
|
|
250
|
+
select 1 from eval_runs r
|
|
251
|
+
where r.eval_target_id = t.id
|
|
252
|
+
and r.created_at > now() - interval '7 days'
|
|
253
|
+
group by r.eval_target_id
|
|
254
|
+
having count(*) >= 5
|
|
255
|
+
and count(*) filter (
|
|
256
|
+
where jsonb_typeof(r.grading->'expectations') = 'array'
|
|
257
|
+
and exists (
|
|
258
|
+
select 1 from jsonb_array_elements(r.grading->'expectations') e
|
|
259
|
+
where coalesce(e->>'errored', 'false') = 'true'
|
|
260
|
+
)
|
|
261
|
+
) * 4 >= count(*)
|
|
262
|
+
)
|
|
263
|
+
-- Health 2b: no human anchors pointing at an ungradeable assertion (absolute).
|
|
264
|
+
and not exists (
|
|
265
|
+
select 1
|
|
266
|
+
from eval_grade_annotations a
|
|
267
|
+
join eval_runs r on r.id = a.eval_run_id
|
|
268
|
+
where a.eval_target_id = t.id
|
|
269
|
+
and jsonb_typeof(r.grading->'expectations') = 'array'
|
|
270
|
+
and coalesce(
|
|
271
|
+
r.grading->'expectations'->(a.expectation_index)->>'errored', 'false'
|
|
272
|
+
) = 'true'
|
|
273
|
+
)
|
|
274
|
+
-- Health 2c: last week's batch wasn't mostly failures.
|
|
275
|
+
and not exists (
|
|
276
|
+
select 1 from eval_runs r
|
|
277
|
+
where r.eval_target_id = t.id
|
|
278
|
+
and r.created_at > now() - interval '7 days'
|
|
279
|
+
group by r.eval_target_id
|
|
280
|
+
having count(*) >= 5
|
|
281
|
+
and count(*) filter (where r.status = 'failed') * 4 >= count(*)
|
|
282
|
+
)
|
|
283
|
+
-- Live cases to measure against — at least MIN_LIVE_CASES (3).
|
|
284
|
+
-- `exists` (>= 1) was the bug: see 00183's header.
|
|
285
|
+
and (
|
|
286
|
+
select count(*) from evals ev
|
|
287
|
+
where ev.eval_target_id = t.id and ev.retired_at is null
|
|
288
|
+
) >= 3
|
|
289
|
+
-- Change budget (cheap exclusion; the API is the authority).
|
|
290
|
+
and (
|
|
291
|
+
select count(*)
|
|
292
|
+
from instruction_promotions p
|
|
293
|
+
where p.eval_target_id = t.id
|
|
294
|
+
and p.applied
|
|
295
|
+
and p.created_at > now() - interval '7 days'
|
|
296
|
+
) < 3
|
|
297
|
+
-- One in flight at a time.
|
|
298
|
+
and not exists (
|
|
299
|
+
select 1 from agent_jobs j
|
|
300
|
+
where j.task_kind = 'cron_instruction_evolution'
|
|
301
|
+
and j.status in ('pending', 'claimed', 'running')
|
|
302
|
+
and j.payload->>'evalTargetId' = t.id::text
|
|
303
|
+
)
|
|
304
|
+
),
|
|
305
|
+
manual as (
|
|
306
|
+
-- The hand-picked path, unchanged and uncapped.
|
|
307
|
+
select h.id, h.user_id
|
|
308
|
+
from health_ok h
|
|
309
|
+
join eval_targets t on t.id = h.id
|
|
310
|
+
where coalesce(t.metadata->>'autoEvolveEnabled', 'false') = 'true'
|
|
311
|
+
),
|
|
312
|
+
auto_ranked as (
|
|
313
|
+
-- The stated rule. Round-robin: never-decided first (nulls first), then
|
|
314
|
+
-- least-recently-decided, so the cap rotates through the candidate pool
|
|
315
|
+
-- instead of re-measuring the same winners nightly.
|
|
316
|
+
select h.id, h.user_id,
|
|
317
|
+
row_number() over (
|
|
318
|
+
partition by h.user_id
|
|
319
|
+
order by (
|
|
320
|
+
select max(p.created_at)
|
|
321
|
+
from instruction_promotions p
|
|
322
|
+
where p.eval_target_id = h.id
|
|
323
|
+
) asc nulls first,
|
|
324
|
+
h.id
|
|
325
|
+
) as rn
|
|
326
|
+
from health_ok h
|
|
327
|
+
join eval_targets t on t.id = h.id
|
|
328
|
+
where t.type = 'instruction'
|
|
329
|
+
and coalesce(t.metadata->>'autoEvolveOptOut', 'false') <> 'true'
|
|
330
|
+
-- Not already covered by the manual path.
|
|
331
|
+
and coalesce(t.metadata->>'autoEvolveEnabled', 'false') <> 'true'
|
|
332
|
+
-- ACTIVE (00145's load-bearing gate): a run in the last 30 days.
|
|
333
|
+
and exists (
|
|
334
|
+
select 1 from eval_runs r
|
|
335
|
+
where r.eval_target_id = t.id
|
|
336
|
+
and r.created_at > now() - interval '30 days'
|
|
337
|
+
)
|
|
338
|
+
),
|
|
339
|
+
eligible as (
|
|
340
|
+
select id, user_id from manual
|
|
341
|
+
union
|
|
342
|
+
select id, user_id from auto_ranked where rn <= 3
|
|
343
|
+
),
|
|
344
|
+
ins as (
|
|
345
|
+
insert into agent_jobs (user_id, task_kind, status, payload, dedupe_key)
|
|
346
|
+
select e.user_id,
|
|
347
|
+
'cron_instruction_evolution',
|
|
348
|
+
'pending',
|
|
349
|
+
jsonb_build_object('evalTargetId', e.id::text)
|
|
350
|
+
|| case
|
|
351
|
+
when public.eval_target_judge_segment(e.id) is not null
|
|
352
|
+
then jsonb_build_object(
|
|
353
|
+
'graderModel', public.eval_target_judge_segment(e.id))
|
|
354
|
+
else '{}'::jsonb
|
|
355
|
+
end,
|
|
356
|
+
'instruction-evolution:' || e.id::text
|
|
357
|
+
from eligible e
|
|
358
|
+
returning 1
|
|
359
|
+
)
|
|
360
|
+
select count(*)::int into queued from ins;
|
|
361
|
+
|
|
362
|
+
return queued;
|
|
363
|
+
end;
|
|
364
|
+
$$;
|
|
365
|
+
|
|
366
|
+
comment on function public.enqueue_instruction_evolution() is
|
|
367
|
+
'Nightly producer for cron_instruction_evolution. Two enrolment paths as of 00166: MANUAL (autoEvolveEnabled=true, uncapped) and the STATED RULE (type=instruction, not opted out via autoEvolveOptOut, an eval run in 30 days, capped at 3/user/night round-robin by least-recent decision). Both share the health gates (2a proportional per 00163, 2b absolute, 2c proportional), live-cases (>= 3 per 00183), change-budget and one-in-flight guards. As of 00193 the payload PINS graderModel to the canonical judge segment — without it the paired replays carried no judge_model_requested, so #357''s fallback quarantine could not see them and they forked the segment freely. Auto-enrolled cycles are measure-only: writing still requires autoEvolveApply AND an explicit immutableSections.';
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
-- An actuator for the refusal the loop had been printing every night.
|
|
2
|
+
--
|
|
3
|
+
-- `cron_instruction_evolution` has been ending in `{"skipped":"not-adequate"}`
|
|
4
|
+
-- with the reason "3/5 cases do not discriminate with vs without — cull or
|
|
5
|
+
-- replace them before adding runs; more runs of a non-discriminating case
|
|
6
|
+
-- cannot lower the MDE; MDE ±49pp exceeds the ±20pp target effect" on every
|
|
7
|
+
-- night measured (2026-08-16, -17, -18). The remedy shipped in 2026-07 as
|
|
8
|
+
-- `rulemetric evals refine-cases <id> --yes` — a command a human types. So the
|
|
9
|
+
-- nightly loop diagnosed its own blocker correctly, spent a two-arm replay
|
|
10
|
+
-- proving it, and had no way to act.
|
|
11
|
+
--
|
|
12
|
+
-- Slot: 05:30, inside the existing nightly chain and deliberately BEFORE the
|
|
13
|
+
-- 06:10 autorun batch, so replacements generated tonight start collecting depth
|
|
14
|
+
-- tonight rather than waiting a day:
|
|
15
|
+
--
|
|
16
|
+
-- 04:30 suggestions -> 04:45 training -> 05:00 auto-accept
|
|
17
|
+
-- -> 05:30 REFINE -> 06:10 autorun -> 07:30 evolution
|
|
18
|
+
--
|
|
19
|
+
-- Opt-in per target (metadata.autoRefineCases), matching autoEvolveEnabled:
|
|
20
|
+
-- refinement rewrites what a target MEASURES, and a measurement apparatus that
|
|
21
|
+
-- reshapes itself without consent cannot then be trusted to report on itself.
|
|
22
|
+
-- The handler re-reads consent when it claims the job, so revoking it stops an
|
|
23
|
+
-- already-queued run.
|
|
24
|
+
-- ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
create or replace function public.enqueue_refine_cases()
|
|
27
|
+
returns integer
|
|
28
|
+
language plpgsql
|
|
29
|
+
security definer
|
|
30
|
+
set search_path = public
|
|
31
|
+
as $$
|
|
32
|
+
declare
|
|
33
|
+
queued integer;
|
|
34
|
+
begin
|
|
35
|
+
with eligible as (
|
|
36
|
+
select t.id, t.user_id
|
|
37
|
+
from eval_targets t
|
|
38
|
+
where coalesce(t.metadata->>'autoRefineCases', 'false') = 'true'
|
|
39
|
+
-- Discrimination is a CROSS-COHORT quantity: without runs in both arms
|
|
40
|
+
-- there is nothing to diagnose, and the handler would spend an API round
|
|
41
|
+
-- trip to say so. Cheap pre-filter, same answer.
|
|
42
|
+
and exists (
|
|
43
|
+
select 1 from eval_runs r
|
|
44
|
+
where r.eval_target_id = t.id
|
|
45
|
+
and r.status = 'completed'
|
|
46
|
+
and r.configuration = 'with_target'
|
|
47
|
+
)
|
|
48
|
+
and exists (
|
|
49
|
+
select 1 from eval_runs r
|
|
50
|
+
where r.eval_target_id = t.id
|
|
51
|
+
and r.status = 'completed'
|
|
52
|
+
and r.configuration = 'without_target'
|
|
53
|
+
)
|
|
54
|
+
-- There must be something left to measure with. A target already at the
|
|
55
|
+
-- floor cannot afford a retirement, and the handler refuses anyway —
|
|
56
|
+
-- but not enqueuing it keeps the refusal out of the nightly feed.
|
|
57
|
+
and (
|
|
58
|
+
select count(*) from evals ev
|
|
59
|
+
where ev.eval_target_id = t.id and ev.retired_at is null
|
|
60
|
+
) >= 1
|
|
61
|
+
-- One in flight at a time, per the 00083/00086 pattern.
|
|
62
|
+
and not exists (
|
|
63
|
+
select 1 from agent_jobs j
|
|
64
|
+
where j.task_kind = 'cron_refine_cases'
|
|
65
|
+
and j.status in ('pending', 'claimed', 'running')
|
|
66
|
+
and j.payload->>'evalTargetId' = t.id::text
|
|
67
|
+
)
|
|
68
|
+
), ins as (
|
|
69
|
+
insert into agent_jobs (user_id, task_kind, status, payload, dedupe_key)
|
|
70
|
+
select e.user_id,
|
|
71
|
+
'cron_refine_cases',
|
|
72
|
+
'pending',
|
|
73
|
+
jsonb_build_object('evalTargetId', e.id::text),
|
|
74
|
+
'refine-cases:' || e.id::text
|
|
75
|
+
from eligible e
|
|
76
|
+
returning 1
|
|
77
|
+
)
|
|
78
|
+
select count(*)::int into queued from ins;
|
|
79
|
+
|
|
80
|
+
return queued;
|
|
81
|
+
end;
|
|
82
|
+
$$;
|
|
83
|
+
|
|
84
|
+
comment on function public.enqueue_refine_cases() is
|
|
85
|
+
'Nightly producer for cron_refine_cases (§6e). Enqueues one refinement per OPTED-IN target (metadata.autoRefineCases=true) that has completed runs in BOTH cohorts and at least one live case. Retires cases carrying no treatment signal and regenerates replacements — the actuator for the "N/M cases do not discriminate" refusal the evolution loop printed nightly with no way to act on it. Runs at 05:30, before the 06:10 autorun slot, so tonight''s replacements collect depth tonight. Opt in with: update eval_targets set metadata = coalesce(metadata, ''{}''::jsonb) || ''{"autoRefineCases":true}''::jsonb where id = ...';
|
|
86
|
+
|
|
87
|
+
select cron.schedule(
|
|
88
|
+
'enqueue_refine_cases',
|
|
89
|
+
'30 5 * * *',
|
|
90
|
+
$$select public.enqueue_refine_cases();$$
|
|
91
|
+
);
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
-- Skills were being USED and the product could not see it.
|
|
2
|
+
--
|
|
3
|
+
-- Measured 2026-08-18: `session_events` holds 158 `Skill` tool invocations over
|
|
4
|
+
-- 30 days, most recent that day, with the skill name sitting in plain sight at
|
|
5
|
+
-- `tool_input->>'skill'`. Meanwhile `session_instructions` held 0 rows for any
|
|
6
|
+
-- skill, `treatment_exposures` had no `skill` type at all, and of 6,086 skill
|
|
7
|
+
-- instructions 0 had ever been accepted and 0 archived. The signal was captured
|
|
8
|
+
-- and dropped on the floor.
|
|
9
|
+
--
|
|
10
|
+
-- That gap is why the loop has never proposed retiring a skill: grooming
|
|
11
|
+
-- reasons over measured effect, and there was no measurement to reason over.
|
|
12
|
+
--
|
|
13
|
+
-- ## Views, not a table
|
|
14
|
+
--
|
|
15
|
+
-- The obvious shape is a `skill_invocations` table written at ingest. It is the
|
|
16
|
+
-- wrong one here: the event side-effects already exist in TWO mirrored copies
|
|
17
|
+
-- (`routes/sessions/ingest/events.ts` and `lib/ingest-batcher.ts`), and a third
|
|
18
|
+
-- dual-write would be a third thing to keep in sync — plus a backfill to get
|
|
19
|
+
-- wrong. A view over the events cannot drift from its source, needs no
|
|
20
|
+
-- backfill, and is correct for all history the moment it exists.
|
|
21
|
+
--
|
|
22
|
+
-- ## Why this is a stronger signal than anything already recorded
|
|
23
|
+
--
|
|
24
|
+
-- Every existing `session_instructions.link_method` — project_match,
|
|
25
|
+
-- context_file, auto_capture, manual — means the instruction was PRESENT in
|
|
26
|
+
-- context. A Skill invocation means it was USED. §12 spends its length on the
|
|
27
|
+
-- gap between exposure and effect; this is the first record on the right side
|
|
28
|
+
-- of that gap, and it is worth keeping distinct from presence rather than
|
|
29
|
+
-- folding into it.
|
|
30
|
+
-- ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
-- Skill events are a rounding error in session_events (158 of ~30k), so a
|
|
33
|
+
-- partial index keeps the scan proportional to what is actually read.
|
|
34
|
+
create index if not exists idx_session_events_skill_tool
|
|
35
|
+
on public.session_events (session_id, timestamp)
|
|
36
|
+
where tool_name = 'Skill';
|
|
37
|
+
|
|
38
|
+
-- Catalog skills are named `<source-repo>--<skill>`; invocations carry either
|
|
39
|
+
-- the bare `<skill>` or `<plugin>:<skill>`. Matching on the leaf therefore
|
|
40
|
+
-- needs the leaf to be indexable — a `like '%--' || name` predicate cannot use
|
|
41
|
+
-- an index at all, and there are 6,086 skill rows to scan without one.
|
|
42
|
+
create index if not exists idx_instructions_skill_leaf
|
|
43
|
+
on public.instructions ((split_part(name, '--', 2)))
|
|
44
|
+
where type = 'skill';
|
|
45
|
+
|
|
46
|
+
-- ---------------------------------------------------------------------------
|
|
47
|
+
-- Raw invocations, parsed.
|
|
48
|
+
-- ---------------------------------------------------------------------------
|
|
49
|
+
create or replace view public.skill_invocations as
|
|
50
|
+
select
|
|
51
|
+
e.session_id,
|
|
52
|
+
e.timestamp as invoked_at,
|
|
53
|
+
e.tool_input->>'skill' as raw_name,
|
|
54
|
+
-- `plugin:skill` is one namespace form Claude Code emits; a bare name is the
|
|
55
|
+
-- other. Splitting rather than storing the raw string keeps the join below
|
|
56
|
+
-- honest about which half is the identity.
|
|
57
|
+
nullif(split_part(e.tool_input->>'skill', ':', 1), e.tool_input->>'skill')
|
|
58
|
+
as plugin,
|
|
59
|
+
case
|
|
60
|
+
when position(':' in e.tool_input->>'skill') > 0
|
|
61
|
+
then split_part(e.tool_input->>'skill', ':', 2)
|
|
62
|
+
else e.tool_input->>'skill'
|
|
63
|
+
end as skill_name
|
|
64
|
+
from public.session_events e
|
|
65
|
+
where e.tool_name = 'Skill'
|
|
66
|
+
and e.tool_input->>'skill' is not null
|
|
67
|
+
and e.tool_input->>'skill' <> '';
|
|
68
|
+
|
|
69
|
+
comment on view public.skill_invocations is
|
|
70
|
+
'Every Skill tool invocation, parsed out of session_events. A view rather than a table: the event side-effects already exist in two mirrored copies and a third dual-write would be a third thing to keep in sync. Names arrive as `<skill>` or `<plugin>:<skill>`.';
|
|
71
|
+
|
|
72
|
+
-- ---------------------------------------------------------------------------
|
|
73
|
+
-- Invocations resolved to the catalog, WITHOUT discarding the unresolved ones.
|
|
74
|
+
--
|
|
75
|
+
-- Measured 2026-08-18: only 11 of 81 distinct invoked skills exist in the
|
|
76
|
+
-- instructions catalog. The other 70 are the user's own local skills
|
|
77
|
+
-- (`git-ship`, `commit`, `publish`, `auto-deploy`) — the ones actually used
|
|
78
|
+
-- every day, and the ones a catalog-only join would silently drop. An inner
|
|
79
|
+
-- join here would have reported 14% of reality and looked complete.
|
|
80
|
+
--
|
|
81
|
+
-- `instruction_id` is therefore NULLABLE and null means "used but not in the
|
|
82
|
+
-- catalog", which is a finding, not a failure.
|
|
83
|
+
-- ---------------------------------------------------------------------------
|
|
84
|
+
create or replace view public.skill_usage as
|
|
85
|
+
select
|
|
86
|
+
si.session_id,
|
|
87
|
+
si.invoked_at,
|
|
88
|
+
si.raw_name,
|
|
89
|
+
si.plugin,
|
|
90
|
+
si.skill_name,
|
|
91
|
+
s.user_id,
|
|
92
|
+
s.project_id,
|
|
93
|
+
s.project_path,
|
|
94
|
+
m.instruction_id
|
|
95
|
+
from public.skill_invocations si
|
|
96
|
+
join public.sessions s on s.id = si.session_id
|
|
97
|
+
left join lateral (
|
|
98
|
+
select i.id as instruction_id
|
|
99
|
+
from public.instructions i
|
|
100
|
+
where i.type = 'skill'
|
|
101
|
+
and (i.name = si.skill_name or split_part(i.name, '--', 2) = si.skill_name)
|
|
102
|
+
-- Exact name beats a leaf match, so a skill whose full name happens to equal
|
|
103
|
+
-- another's leaf cannot steal the attribution.
|
|
104
|
+
order by (i.name = si.skill_name) desc, i.id
|
|
105
|
+
limit 1
|
|
106
|
+
) m on true;
|
|
107
|
+
|
|
108
|
+
comment on view public.skill_usage is
|
|
109
|
+
'Skill invocations joined to the session that ran them and, where one exists, the catalog instruction. instruction_id is NULL for skills used but not catalogued — measured 2026-08-18, that was 70 of 81 distinct skills, so an inner join here would report 14% of reality.';
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
-- The loop's first unattended REMOVAL.
|
|
2
|
+
--
|
|
3
|
+
-- Everything the loop has actuated until now has added: a rule appended to
|
|
4
|
+
-- CLAUDE.md, a skill installed, a hook registered, an instruction promoted.
|
|
5
|
+
-- Grooming's stated contract was that removals are surfaced for human review
|
|
6
|
+
-- and NEVER auto-archived, and this producer deliberately changes that for one
|
|
7
|
+
-- narrow case (owner's decision, 2026-08-18). Recording the change here so the
|
|
8
|
+
-- next reader finds the departure rather than inferring it.
|
|
9
|
+
--
|
|
10
|
+
-- What makes it narrow:
|
|
11
|
+
--
|
|
12
|
+
-- * ONLY skills, and only ones with ZERO recorded invocations since install.
|
|
13
|
+
-- "Invoked but measures unhelpful" is an observational estimate and stays a
|
|
14
|
+
-- proposal, exactly as measured-harmful rules do. An absence of use is a
|
|
15
|
+
-- fact; an absence of benefit is an inference.
|
|
16
|
+
-- * ONLY skills the loop itself installed, evidenced by a
|
|
17
|
+
-- `recommendation_applications` row with status='applied'. The handler
|
|
18
|
+
-- re-checks this; the API is the authority.
|
|
19
|
+
-- * ONLY while skill capture is demonstrably live. A dark Skill channel makes
|
|
20
|
+
-- every skill look unused, and the first quiet fortnight would otherwise
|
|
21
|
+
-- read as "retire everything". Judged by `loadUnusedSkillCandidates`.
|
|
22
|
+
-- * A per-run cap in the handler, and a backup written before every delete
|
|
23
|
+
-- (`applyRetraction`) because an untracked skill file has no other copy and
|
|
24
|
+
-- git cannot restore it.
|
|
25
|
+
--
|
|
26
|
+
-- Slot: 05:45, after auto-accept (05:00) and refine (05:30), before the autorun
|
|
27
|
+
-- batch (06:10). Adding and removing in the same nightly pass, in that order,
|
|
28
|
+
-- means a skill adopted tonight is never a retirement candidate tonight — it
|
|
29
|
+
-- has to survive the unused window first.
|
|
30
|
+
--
|
|
31
|
+
-- 04:30 suggestions -> 04:45 training -> 05:00 auto-accept
|
|
32
|
+
-- -> 05:30 refine -> 05:45 RETIRE -> 06:10 autorun -> 07:30 evolution
|
|
33
|
+
--
|
|
34
|
+
-- Opt-in per project (metadata.autoRetireSkills). Nothing is retired for a user
|
|
35
|
+
-- who has not asked for it, and the handler cannot be reached without a job.
|
|
36
|
+
-- ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
create or replace function public.enqueue_retire_unused_skills()
|
|
39
|
+
returns integer
|
|
40
|
+
language plpgsql
|
|
41
|
+
security definer
|
|
42
|
+
set search_path = public
|
|
43
|
+
as $$
|
|
44
|
+
declare
|
|
45
|
+
queued integer;
|
|
46
|
+
begin
|
|
47
|
+
with eligible as (
|
|
48
|
+
-- The canonical checkout comes from the VIEW, never re-derived: is_worktree
|
|
49
|
+
-- is rewritten from each session's own metadata on every ingest, so a
|
|
50
|
+
-- hand-rolled ranking elects a path nobody works in (00186).
|
|
51
|
+
select pr.created_by as user_id, p.path as canonical_path
|
|
52
|
+
from public.project_canonical_checkouts p
|
|
53
|
+
join public.projects pr on pr.id = p.project_id
|
|
54
|
+
where coalesce(pr.metadata->'autoRetireSkills'->>'enabled', 'false') = 'true'
|
|
55
|
+
-- Cheap pre-filter: a user with no skill invocations captured at all has
|
|
56
|
+
-- a dark channel, and the handler would refuse anyway. Not enqueuing it
|
|
57
|
+
-- keeps a guaranteed refusal out of the nightly feed.
|
|
58
|
+
and exists (
|
|
59
|
+
select 1 from public.skill_usage su
|
|
60
|
+
where su.user_id = pr.created_by
|
|
61
|
+
and su.invoked_at > now() - interval '14 days'
|
|
62
|
+
)
|
|
63
|
+
-- There must be something that could plausibly be retired: at least one
|
|
64
|
+
-- of the user's own skills old enough to have had a fair chance.
|
|
65
|
+
and exists (
|
|
66
|
+
select 1 from public.instructions i
|
|
67
|
+
where i.created_by = pr.created_by
|
|
68
|
+
and i.type = 'skill'
|
|
69
|
+
and i.archived = false
|
|
70
|
+
and i.created_at < now() - interval '30 days'
|
|
71
|
+
)
|
|
72
|
+
-- One in flight at a time, per the 00083/00086 pattern.
|
|
73
|
+
and not exists (
|
|
74
|
+
select 1 from public.agent_jobs j
|
|
75
|
+
where j.task_kind = 'cron_retire_unused_skills'
|
|
76
|
+
and j.status in ('pending', 'claimed', 'running')
|
|
77
|
+
and j.payload->>'projectPath' = p.path
|
|
78
|
+
)
|
|
79
|
+
), ins as (
|
|
80
|
+
insert into agent_jobs (user_id, task_kind, status, payload, dedupe_key)
|
|
81
|
+
select e.user_id,
|
|
82
|
+
'cron_retire_unused_skills',
|
|
83
|
+
'pending',
|
|
84
|
+
jsonb_build_object('projectPath', e.canonical_path),
|
|
85
|
+
'retire-unused-skills:' || e.canonical_path
|
|
86
|
+
from eligible e
|
|
87
|
+
returning 1
|
|
88
|
+
)
|
|
89
|
+
select count(*)::int into queued from ins;
|
|
90
|
+
|
|
91
|
+
return queued;
|
|
92
|
+
end;
|
|
93
|
+
$$;
|
|
94
|
+
|
|
95
|
+
comment on function public.enqueue_retire_unused_skills() is
|
|
96
|
+
'Nightly producer for cron_retire_unused_skills (05:45). Enqueues one run per OPTED-IN project (projects.metadata.autoRetireSkills.enabled=true) whose user has live skill capture and at least one own skill older than the unused window. The loop''s first unattended REMOVAL: only skills, only never-invoked ones, only ones the loop itself installed, only while skill capture is live, capped per run, and with a backup written before every delete. Used-but-unhelpful skills remain proposals. Opt in with: update projects set metadata = coalesce(metadata, ''{}''::jsonb) || ''{"autoRetireSkills":{"enabled":true}}''::jsonb where id = ...';
|
|
97
|
+
|
|
98
|
+
select cron.schedule(
|
|
99
|
+
'enqueue_retire_unused_skills',
|
|
100
|
+
'45 5 * * *',
|
|
101
|
+
$$select public.enqueue_retire_unused_skills();$$
|
|
102
|
+
);
|