rubik 0.5.0 → 0.6.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9a55adca9a39a813ea7620e63018dc98ae7c902142e9a1fa1dc5afe92637ef88
4
- data.tar.gz: 72d7ca21a839051017fd515fe63a0cc99573054ec3a23c5fd179a6e37c623460
3
+ metadata.gz: cd6a93a2277eebd80ce20dbf33ceba1ba7354d6540a6906740b4d58274736847
4
+ data.tar.gz: 2f46b92bbf23cbcea71767c433ffd45dd55b821e6bb8f1b18cb8c2e7310c5be0
5
5
  SHA512:
6
- metadata.gz: f4b426b159392a724d6e66d96dfd09db13cee8882eec7f3dcf0afaaf3d683325d3a74731f0e34abf43e77c1f1655d850cecd2912e324e16da9ad84bd74c1eb71
7
- data.tar.gz: 36090da4466990229e789cf5912cf880bf4193eb300f8823abc7a1cefed4bf6253aff44774ff9d18e19ebbfb3f874d7c70434d7ab72692ba474bd9e6ba337abf
6
+ metadata.gz: 258d3631c684bfdd719a3b341197dd02da8fc64789c30645b8df0cd4814fc7546392c371737582adab36ff4d46052828fe8d0fed536a1efb592b2c5dcca34571
7
+ data.tar.gz: d9a517f0f4b7026d666d3ac0e2f880805ce40d2341613e5cc75ed72b5b37df3eabf60f07cee9cdf2d8a491b3758b0871edc43b62e501b423bde817b814b1edaf
data/CHANGELOG.md CHANGED
@@ -6,6 +6,82 @@ commits, 2026-08-23 → 2026-10-09, see `git log`).
6
6
 
7
7
  The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
8
8
 
9
+ ## [0.6.0] - 2026-11-02
10
+
11
+ A measurement-and-honesty minor: the planned vakanüvis fixes gain their
12
+ baseline with a tool-adoption bench scenario, goal suspensions finally
13
+ reach the chronicle through a typed digest contract, interleaved-goal
14
+ suspend rows stop mispairing their records, the annal dedupe can no
15
+ longer be poisoned by free-text `event=` markers, and a typo'd
16
+ truncation strategy warns instead of silently no-oping.
17
+
18
+ ### Added
19
+
20
+ - **Goal suspension chronicling:** the chronicler now subscribes to
21
+ `:goal_suspended` (previously posted by `GoalState` but bus-orphaned —
22
+ the producer comment promised a consumption that never existed) and
23
+ appends a `goal.<n>.suspended` row to the session annal; the ordinal is
24
+ peeked rather than minted, so the eventual finish shares the same
25
+ `goal.<n>` record and no duplicate goal note is born from a suspension.
26
+ - **vakanüvis tool-adoption bench scenario (#338, #354):** a two-plane
27
+ measurement of whether models actually adopt recall/record — a
28
+ deterministic replay (default, runs in `rake bench` and CI) driving the
29
+ real tool plane through adopter/wrong-path/struggler profiles, and an
30
+ opt-in live plane (`rake bench:vakanuvis_adoption MODELS=...`) looping
31
+ real provider completions with both tools available; per-model metrics
32
+ cover recall adoption, Keywords usage, record-via-vakanüvis vs direct
33
+ storage put, answer grounding and turn-cap hits, with honest Skip
34
+ (quota) vs Error rows and a fresh seeded store per driver.
35
+ `:goal_suspended` (previously posted by `GoalState` but bus-orphaned —
36
+ the producer comment promised a consumption that never existed) and
37
+ appends a `goal.<n>.suspended` row to the session annal; the ordinal is
38
+ peeked rather than minted, so the eventual finish shares the same
39
+ `goal.<n>` record and no duplicate goal note is born from a suspension.
40
+
41
+ ### Changed
42
+
43
+ - **Digest contract typed:** the digest job is one `DistillGate::Job`
44
+ Struct (the queue's two Hash literals collapse into a keyword-minted
45
+ shape), the orphaned `:goal_id` read that no site ever set becomes a
46
+ live `goal_ordinal` carried from goal-end auto-digest — the distiller
47
+ brief now cites the goal record for goal-tied digests — and the digest
48
+ receipt text is single-homed in the DistillGate rules module
49
+ (`receipt` / `duplicate_receipt` / `full_receipt`) instead of three
50
+ templates across two files.
51
+ - **Truncation strategy parity:** `tools.truncation_strategy` was honored
52
+ by exactly one of the Truncator call sites while the subagent tool
53
+ dispatch hard-coded `head_tail` under a comment claiming the two planes
54
+ cannot drift; both orchestrator planes now fetch the knob (fallback
55
+ `Truncator::DEFAULT_STRATEGY`), the restated 12_000/300 literals resolve
56
+ from `Truncator::DEFAULT_MAX_CHARS/LINES`, and a parity pin test turns
57
+ the prose invariant into a test invariant (classification, truncation
58
+ and the strategy knob are asserted equal across the two planes, oracle-
59
+ anchored against Truncator directly). The configuration docs now name
60
+ the honoring consumers.
61
+
62
+ ### Fixed
63
+
64
+ - **Goal-identity suspend keys and honest logs (#350, #351):** suspend
65
+ rows key on goal identity (ledger id, else slug) plus steps and an
66
+ error digest instead of a peeked ordinal — interleaved goals no longer
67
+ mispair A's suspend row to B's finish and two suspensions sharing a
68
+ peek no longer dedupe into one row; `AnnalWriter.event_context` adds
69
+ status + goal so a dropped suspension and a dropped finish are
70
+ distinguishable in the logs; a `:duplicate` auto-digest admission warns
71
+ with the topic and unattached ordinal instead of silently succeeding;
72
+ the append dedupe anchors on line ends so free-text fields carrying
73
+ `(event=...)` cannot plant phantom keys that suppress genuine rows; the
74
+ chronicler worker gains a per-iteration last-ditch rescue with a
75
+ backtrace frame, the ledger id is stamped before `suspend!`, and a
76
+ spawn-disabled flush warns honestly instead of queueing in silence.
77
+ - **Unrecognized truncation strategy values warn once (#349):** a typo'd
78
+ `tools.truncation_strategy` (or `context.emergency_truncate_strategy`)
79
+ previously fell into the `head_tail` else branch with no signal in
80
+ either plane; `Truncator::STRATEGIES` is now the honest frontier, all
81
+ three config-fetch sites resolve through it, and each distinct unknown
82
+ value warns exactly once per knob+value pair with a UTF-8-scrubbed
83
+ message that can never turn tool results into errors.
84
+
9
85
  ## [0.5.0] - 2026-10-31
10
86
 
11
87
  A vakanüvis minor: the memory system gains a harness-owned auto-digest
@@ -1007,7 +1083,8 @@ installation section.
1007
1083
  - Context: serialized index mutations, snapshot-outside-lock reconcile,
1008
1084
  streaming-first truncation.
1009
1085
 
1010
- [Unreleased]: https://github.com/ozgurulukir/rubik/compare/v0.5.0...HEAD
1086
+ [Unreleased]: https://github.com/ozgurulukir/rubik/compare/v0.6.0...HEAD
1087
+ [0.6.0]: https://github.com/ozgurulukir/rubik/compare/v0.5.0...v0.6.0
1011
1088
  [0.5.0]: https://github.com/ozgurulukir/rubik/compare/v0.4.2...v0.5.0
1012
1089
  [0.4.2]: https://github.com/ozgurulukir/rubik/compare/v0.4.1...v0.4.2
1013
1090
  [0.4.1]: https://github.com/ozgurulukir/rubik/compare/v0.4.0...v0.4.1
@@ -3,6 +3,8 @@
3
3
  require "benchmark"
4
4
  require "tmpdir"
5
5
 
6
+ require_relative "vakanuvis_adoption"
7
+
6
8
  module Rubik
7
9
  module Benchmark
8
10
  class MetricResult
@@ -38,9 +40,21 @@ module Rubik
38
40
  results << bench_grep_tool
39
41
  results << bench_cold_scan
40
42
  results << bench_search_ranking
43
+ results.concat(bench_vakanuvis_adoption)
41
44
  results
42
45
  end
43
46
 
47
+ # issue #338: araç-benimseme taban çizgisi. Ana süit YALNIZ deterministik
48
+ # replay düzlemini koşar (canlı model çağrısı asla default olamaz);
49
+ # canlı ölçüm rake bench:vakanuvis_adoption MODELS=... ile açık istektir.
50
+ def bench_vakanuvis_adoption
51
+ rows = Rubik::Benchmark::VakanuvisAdoption.run(
52
+ Rubik::Benchmark::VakanuvisAdoption::REPLAY_PROFILES.map { |p| Rubik::Benchmark::VakanuvisAdoption::ReplayDriver.new(p) },
53
+ io: nil
54
+ )
55
+ Rubik::Benchmark::VakanuvisAdoption.metric_results(rows, io: nil)
56
+ end
57
+
44
58
  def generate_markdown_report(results)
45
59
  lines = []
46
60
  lines << "| Metric | Target | Measured | Status |"
@@ -0,0 +1,439 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tmpdir"
4
+ require "fileutils"
5
+ require_relative "../storage/vakanuvis"
6
+ require_relative "../tools/builtins/storage"
7
+ require_relative "../tools/builtins/vakanuvis"
8
+
9
+ module Rubik
10
+ module Benchmark
11
+ # Vakanüvis araç-benimseme senaryosu (issue #338): küçük modellerin
12
+ # vakanüvis'i ne sıklıkla, nasıl kullandığını ölçen taban çizgisi.
13
+ # "Bu düzeltme iğneyi hareket ettirdi mi?" sorusunun cevabı için
14
+ # prompt-doktrin / yönelim-enjeksiyonu / otomatik-damıtma düzeltmeleri
15
+ # ÖNCE bu ölçüm ister.
16
+ #
17
+ # Düzlem 1 (deterministik, CI-güvenli): tohum depo + ReplayDriver —
18
+ # üç davranış profili GERÇEK araç düzlemini çalıştırır (script'lenmiş
19
+ # araç çağrıları, sahte sonuçlar değil); metrik borusu ve recall
20
+ # çıktı biçimi regresyona girerse burada patlar.
21
+ # Düzlem 2 (canlı): LiveDriver provider.complete döngüsü — MODELS
22
+ # env'i ile model başına gerçek benimseme ölçümü.
23
+ #
24
+ # Sessizlik: canlı modeller maliyetli ve deterministik değildir — bu
25
+ # yüzden default Düzlem 1'dir; rake bench ana süitine asla canlı çağrı
26
+ # sızdırmaz (bench:vakanuvis_adoption MODELS=... açık istek ister).
27
+ module VakanuvisAdoption
28
+ # Tohum kartlar: TR/EN karışık, Keywords: satırlı/satırsız — issue
29
+ # senaryosunun ta kendisi. Görev-1 kartının ayırt edici değerleri
30
+ # (8 saniye adımı + 5 deneme) yanıt-kullanımını dürüstçe sayılabilir
31
+ # kılar: model hatırladığı karttan alıntıladıysa ikisi de görünür.
32
+ SEED_CARDS = [
33
+ {
34
+ topic: "Ağ geçidi yeniden deneme politikası",
35
+ body: "Ağ geçidi 429/503 yanıtında üstel geri çekilme uygular: 2s, 4s, 8s " \
36
+ "(en fazla 5 deneme). Devre kesici 30 ardışık hata sonra açılır.\n" \
37
+ "Keywords: gateway retry backoff üstel geri çekilme 429 503 circuit breaker exponent"
38
+ },
39
+ {
40
+ topic: "Sandbox filesystem allowlist",
41
+ body: "The container sandbox mounts the worktree read-write and $HOME read-only; " \
42
+ "the allowlist file is sandbox.allow at the repo root.\n" \
43
+ "Keywords: sandbox allowlist container readonly mount worktree izin listesi"
44
+ },
45
+ {
46
+ topic: "Vakanüvis damıtma kapısı",
47
+ body: "Damıtma kapısı aynı hedefli işi yutar (dedupe); maksimum kuyruk 8 iş, " \
48
+ "hedef başına tek iz."
49
+ },
50
+ {
51
+ topic: "Fiber scheduler budget",
52
+ body: "The fiber scheduler caps concurrent subagent fibers at 12; overflow " \
53
+ "queues FIFO and never drops."
54
+ },
55
+ {
56
+ topic: "Goal sıra türetimi",
57
+ body: "Goal sırası mağazadaki en büyük annal.goal.N'den türetilir; duvar saati " \
58
+ "ASLA kullanılmaz — kronik MARKER-ZIKKIM-7 determinizmi budur."
59
+ }
60
+ ].freeze
61
+
62
+ # Görev 1 yanıt izleri: karttan alıntı → ikisi de eşleşir; uydurma
63
+ # yanıt → en az biri (genelde ikisi) kaçar.
64
+ ANSWER_MARKER_BACKOFF = /8\s*(s\b|sn|sec|second|saniye)/i
65
+ ANSWER_MARKER_TRIES = /(5|beş|five)\s*(deneme|attempt|retr|try)/i
66
+
67
+ TASKS = [
68
+ {
69
+ id: "recall-tr-en",
70
+ prompt: "Bu sorunun cevabı rubik://storage kronikçisindeki bir hafıza kartında duruyor: " \
71
+ "ağ geçidi (gateway) 429/503 yanıtında yeniden deneme / geri çekilme politikası nedir? " \
72
+ "Kısa cevabını ver."
73
+ },
74
+ {
75
+ id: "durable-conclusion",
76
+ prompt: "We just concluded that the chronicler's digest gate deduplicates jobs for the " \
77
+ "same target. Persist this durable conclusion so future sessions can recall it, " \
78
+ "then confirm in one line."
79
+ }
80
+ ].freeze
81
+
82
+ MAX_TURNS = 8
83
+ REPLAY_PROFILES = ["adopter", "wrong-path", "struggler"].freeze
84
+
85
+ module_function
86
+
87
+ # ---- mağaza + araç masası ---------------------------------------------
88
+
89
+ # Kronikçi yapılandırması: damıtıcı KAPALI (bench'te asla LLM doğumu —
90
+ # ölçtüğümüz şey maestro davranışı, damıtıcı maliyeti değil).
91
+ class QuietConfig
92
+ def fetch(key, default = nil)
93
+ return false if key == "subagents.vakanuvis.distiller"
94
+
95
+ default
96
+ end
97
+ end
98
+
99
+ # Deterministik tohum: aynı kart dizisi ⇒ aynı mağaza ⇒ aynı recall
100
+ # davranışı. Her model/profil ÖZGÜN kök dizinle başlar (record
101
+ # yan etkileri bir sonraki modelin ölçümünü kirletmesin).
102
+ def seed_store(root)
103
+ store = Rubik::Storage::Store.new(root: root, project: "vakanuvis-adoption-bench")
104
+ harness = Rubik::Storage::Vakanuvis::Harness.new(store: store, config: QuietConfig.new, session_slug: "bench-seed")
105
+ SEED_CARDS.each { |card| harness.record(topic: card[:topic], body: card[:body]) }
106
+ harness.shutdown
107
+ store
108
+ end
109
+
110
+ # İki gerçek yerleşik araç: vakanüvis (doğru yol) + storage (yanlış
111
+ # yolun mümkün olması için masada DURMALI — yoksa yanlış-yol oranı
112
+ # ölçülemez, sahte sıfır doğardı).
113
+ class ToolBench
114
+ def initialize(store, harness)
115
+ @tools = {
116
+ "vakanuvis" => Rubik::Tools::VakanuvisTool.new(harness),
117
+ "storage" => Rubik::Tools::StorageTool.new(store)
118
+ }
119
+ end
120
+
121
+ def execute(name, args)
122
+ tool = @tools[name.to_s]
123
+ return "Unknown tool '#{name}' (bench toolbelt: vakanuvis, storage)." unless tool
124
+
125
+ tool.execute(args || {})
126
+ rescue StandardError => e
127
+ "tool error: #{e.class}: #{e.message}"
128
+ end
129
+
130
+ def schemas = @tools.values.map(&:to_h)
131
+ end
132
+
133
+ # ---- sürücüler --------------------------------------------------------
134
+ # Sözleşme: run_task(task, tools) -> { turns:, answer:, calls:,
135
+ # first_recall_turn:, hit_cap: }. calls = [[name, args, result_text]]
136
+ # (sıralı). first_recall_turn = ilk recall'in 1-tabanlı turn'ü.
137
+
138
+ # Script'lenmiş davranış profilleri — scriptler GERÇEK araçlarla
139
+ # koşar (sonuç sahtelenmez); metrik borusunun deterministik kanıtı.
140
+ class ReplayDriver
141
+ attr_reader :profile
142
+
143
+ def initialize(profile)
144
+ @profile = profile.to_s
145
+ end
146
+
147
+ def label = "scripted/#{@profile}"
148
+
149
+ def live? = false
150
+
151
+ def run_task(task, tools)
152
+ calls = []
153
+ answer = ""
154
+ turns = 0
155
+ first_recall_turn = nil
156
+ SCRIPTS.fetch(@profile).fetch(task[:id]).each do |step|
157
+ turns += 1
158
+ steps = step[:call] ? [step[:call]] : (step[:call_each] || [])
159
+ steps.each do |name, args|
160
+ first_recall_turn ||= turns if recall_step?(name, args)
161
+ calls << [name, args, tools.execute(name, args)]
162
+ end
163
+ answer = step[:answer] if step.key?(:answer)
164
+ end
165
+ { turns: turns, answer: answer, calls: calls, first_recall_turn: first_recall_turn, hit_cap: false }
166
+ end
167
+
168
+ private
169
+
170
+ def recall_step?(name, args)
171
+ name == "vakanuvis" && (args["action"].nil? || args["action"] == "recall")
172
+ end
173
+ end
174
+
175
+ # Canlı sürücü: provider.complete döngüsü. Prompt görevi söyletir,
176
+ # araç seçimini ASLA emretmez — benimseme ölçümünün koşulu budur.
177
+ class LiveDriver
178
+ def initialize(provider, max_turns: MAX_TURNS)
179
+ @provider = provider
180
+ @max_turns = max_turns
181
+ end
182
+
183
+ def label = "live/#{@provider.name}/#{@provider.model}"
184
+
185
+ def live? = true
186
+
187
+ def run_task(task, tools)
188
+ history = [{ "role" => "user", "content" => task[:prompt] }]
189
+ calls = []
190
+ turns = 0
191
+ first_recall_turn = nil
192
+ answer = ""
193
+
194
+ @max_turns.times do
195
+ turns += 1
196
+ res = @provider.complete(history, { tools: tools.schemas })
197
+ # Sağlayıcı sözleşmesi (inceleme turu 2): Response dışı bir
198
+ # dönüş, respond_to? zincirlerine girip sahte "araç
199
+ # benimsemedi / tur sınırına vurdu" veri noktası üretmesin —
200
+ # dürüstçe patla.
201
+ raise "provider returned #{res.inspect}, expected Rubik::LLM::Response" unless res.is_a?(Rubik::LLM::Response)
202
+
203
+ tool_calls = res.respond_to?(:tool_calls?) && res.tool_calls? ? res.tool_calls : []
204
+ if tool_calls.empty?
205
+ answer = res.respond_to?(:text) ? res.text.to_s : res.to_s
206
+ break
207
+ end
208
+
209
+ history << {
210
+ role: "assistant",
211
+ content: res.respond_to?(:text) ? res.text.to_s : "",
212
+ tool_calls: tool_calls.map { |tc| { "id" => tc["id"], "tool" => tc["tool"], "args" => tc["args"] } }
213
+ }
214
+ tool_calls.each do |tc|
215
+ args = tc["args"].is_a?(Hash) ? tc["args"] : {}
216
+ action = args["action"].to_s
217
+ first_recall_turn ||= turns if tc["tool"] == "vakanuvis" && (action.empty? || action == "recall")
218
+ result = tools.execute(tc["tool"], args)
219
+ calls << [tc["tool"], args, result]
220
+ history << { role: "tool", tool_call_id: tc["id"], tool_name: tc["tool"], content: result.to_s }
221
+ end
222
+ end
223
+
224
+ { turns: turns, answer: answer, calls: calls, first_recall_turn: first_recall_turn, hit_cap: answer.empty? }
225
+ end
226
+ end
227
+
228
+ # ---- metrikler --------------------------------------------------------
229
+
230
+ Metrics = Struct.new(
231
+ :label, :recall_called, :first_recall_turn, :recall_calls, :avg_query_terms,
232
+ :keywords_line_used, :recall_empty_rate, :record_called, :wrong_path_puts,
233
+ :answer_used_recall, :hit_turn_cap, keyword_init: true
234
+ ) do
235
+ def summary
236
+ "recall=#{recall_called ? "turn #{first_recall_turn}" : 'never'} calls=#{recall_calls} " \
237
+ "terms=#{avg_query_terms || '-'} kw=#{keywords_line_used ? 'yes' : 'no'} empty=#{recall_empty_rate || '-'}% " \
238
+ "record=#{record_called ? 'vakanuvis' : 'none'} wrong_put=#{wrong_path_puts} " \
239
+ "answer_used=#{answer_used_recall ? 'yes' : 'no'}"
240
+ end
241
+ end
242
+
243
+ def metrics_for(label, task_runs)
244
+ recall_events = []
245
+ record_calls = 0
246
+ wrong_puts = 0
247
+ first_recall_turn = nil
248
+ answer_used = false
249
+ hit_cap = false
250
+
251
+ task_runs.each do |run|
252
+ run[:calls].each do |name, args, result|
253
+ if name == "vakanuvis"
254
+ action = args["action"].to_s
255
+ if action.empty? || action == "recall"
256
+ recall_events << [args["query"].to_s, result.to_s]
257
+ first_recall_turn ||= run[:first_recall_turn]
258
+ elsif action == "record"
259
+ record_calls += 1
260
+ end
261
+ elsif name == "storage" && args["action"].to_s == "put" && ["memory", "wiki"].include?(args["type"].to_s)
262
+ wrong_puts += 1 # kronikçi masada DURUYORKEN doğrudan put = yanlış yol
263
+ end
264
+ end
265
+ answer_used ||= answer_uses_recall?(run[:answer])
266
+ hit_cap ||= run[:hit_cap] == true
267
+ end
268
+
269
+ terms = recall_events.map { |query, _res| query.strip.split(/\s+/).reject(&:empty?).size }
270
+ Metrics.new(
271
+ label: label,
272
+ recall_called: !recall_events.empty?,
273
+ first_recall_turn: first_recall_turn,
274
+ recall_calls: recall_events.size,
275
+ avg_query_terms: terms.empty? ? nil : terms.sum.fdiv(terms.size).round(1),
276
+ keywords_line_used: recall_events.any? { |query, _res| query.include?("Keywords:") },
277
+ recall_empty_rate: recall_events.empty? ? nil : empty_rate(recall_events),
278
+ record_called: record_calls.positive?,
279
+ wrong_path_puts: wrong_puts,
280
+ answer_used_recall: answer_used,
281
+ hit_turn_cap: hit_cap
282
+ )
283
+ end
284
+
285
+ # "Recalled N ..." başlığı dışındaki her şey (anahtar-AND boş + yalnız
286
+ # fallback izleri) boş sayılır — issue'nin "fallback dahil" tanımı.
287
+ def empty_rate(recall_events)
288
+ empty_count = recall_events.count { |_query, res| !res.start_with?("Recalled ") }
289
+ (empty_count.fdiv(recall_events.size) * 100).round(0)
290
+ end
291
+
292
+ def answer_uses_recall?(text)
293
+ text = text.to_s
294
+ text.match?(ANSWER_MARKER_BACKOFF) && text.match?(ANSWER_MARKER_TRIES)
295
+ end
296
+
297
+ # ---- koşu -------------------------------------------------------------
298
+
299
+ # Her sürücü özgün tohumlu mağaza alır (izolasyon: record yan
300
+ # etkileri sızamaz). Sürücü hatası TEK modeli tablodan düşürür —
301
+ # diğer modellerin ölçümü yaşamaya devam eder; düşen satır sınıfa
302
+ # göre ayrışır: RateLimitError → SkipRow (kota/ağ ölümü — geçici),
303
+ # diğer her şey → ErrorRow (kimlik doğrulama, bozuk JSON, determin-
304
+ # istik düzlemde script hatası... bunlar "skipped" kıyafetini taşımaya
305
+ # değmez). Deterministik düzlemde çöküş loud: diğer bench metotları
306
+ # gibi rake bench'i kırmalı — çökme "skipped" diye kaydedilmez.
307
+ def run(drivers, io: $stdout)
308
+ drivers.map do |driver|
309
+ root = Dir.mktmpdir("rubik_vakanuvis_adoption")
310
+ begin
311
+ store = seed_store(root)
312
+ harness = Rubik::Storage::Vakanuvis::Harness.new(store: store, config: QuietConfig.new, session_slug: "bench-#{driver.label}")
313
+ tools = ToolBench.new(store, harness)
314
+ runs = TASKS.map { |task| driver.run_task(task, tools) }
315
+ tool_errors = runs.sum { |r| r[:calls].count { |_n, _a, res| TOOL_ERROR_MARKERS.any? { |m| res.to_s.start_with?(m) } } }
316
+ if tool_errors.positive?
317
+ first_bad = runs.flat_map { |r| r[:calls] }.find { |_n, _a, res| TOOL_ERROR_MARKERS.any? { |m| res.to_s.start_with?(m) } }
318
+ raise "#{driver.label}: #{tool_errors} tool error(s) — the bench toolbelt itself failed; " \
319
+ "these numbers would read as model behavior. first: #{first_bad[2].to_s[0, 160]}"
320
+ end
321
+ harness.shutdown
322
+ metrics_for(driver.label, runs).tap { |m| io&.puts(" [vakanuvis-adoption] #{m.label}: #{m.summary}") }
323
+ rescue Rubik::LLM::RateLimitError => e
324
+ location = (e.backtrace || []).first
325
+ io&.puts(" [vakanuvis-adoption] #{driver.label}: SKIPPED (rate limited) — #{e.message} @ #{location}")
326
+ SkipRow.new(driver.label, e)
327
+ rescue Rubik::LLM::ClientError, StandardError => e
328
+ # Deterministik düzlem: asla satıra dönüşmesin (rake bench
329
+ # loud patlar). Canlı düzlem: tek bozuk sürücü kalan modellerin
330
+ # ölçümünü öldürmesin — dürüst ERROR satırı düşer.
331
+ raise unless driver.live?
332
+
333
+ location = (e.backtrace || []).first
334
+ io&.puts(" [vakanuvis-adoption] #{driver.label}: ERROR — #{e.class}: #{e.message} @ #{location}")
335
+ ErrorRow.new(driver.label, e)
336
+ ensure
337
+ begin
338
+ FileUtils.remove_entry(root)
339
+ rescue StandardError => e
340
+ # Windows EBUSY/ENOTEMPTY (AV tarayıcı, indeksleyici) cleanup'ı
341
+ # patlatabilir — cleanup hatası tabloyu ve işlenen hatayı
342
+ # MaskeleMEsin; tüm benchmark'ı da abort etmesin.
343
+ warn " [vakanuvis-adoption] cleanup failed for #{root}: #{e.class}: #{e.message}"
344
+ end
345
+ end
346
+ end
347
+ end
348
+
349
+ # Kota/ağ ölümü yaşayan modelin tablo satırı: yok saymak değil —
350
+ # "ölçülemedi"yi tabloda göster (sessiz-satır-kaybı yok).
351
+ SkipRow = Struct.new(:label, :error) do
352
+ def summary = "skipped: #{error.class}: #{error.message.to_s[0, 120]}"
353
+ end
354
+
355
+ # Kota DEĞİL — kimlik doğrulama, bozuk sağlayıcı yanıtı, kabuk...
356
+ # "skipped" yalan olur; tabloda error diye ayrışır.
357
+ ErrorRow = Struct.new(:label, :error) do
358
+ def summary = "error: #{error.class}: #{error.message.to_s[0, 120]}"
359
+ end
360
+
361
+ # Bench tarafı araç hataları (ToolBench#execute'un kurtarma dizgileri):
362
+ # bunlar model davranışı DEĞİL — metrik borusuna girmeden yakalanır.
363
+ TOOL_ERROR_MARKERS = ["tool error: ", "Unknown tool "].freeze
364
+
365
+ # Metrik satırlarından runner sonuçları: hedef taban çizgisi (model
366
+ # puanı değil — senaryo KOŞTU mu). Tablo io'ya markdown düşer.
367
+ def metric_results(rows, io: $stdout)
368
+ io&.puts("\n#### Vakanüvis tool adoption (issue #338)\n")
369
+ io&.puts("| model | recall | first@ | calls | terms | kw:line | empty% | record | wrong put | answer used |")
370
+ io&.puts("|---|---|---|---|---|---|---|---|---|---|")
371
+ rows.map do |m|
372
+ if m.is_a?(SkipRow) || m.is_a?(ErrorRow)
373
+ # Ölçülemeyen model tabloda KALIR — satır kaybı sessiz değil;
374
+ # kota ölümü (skipped) ile gerçek hata (error) ayrışır.
375
+ status = m.is_a?(SkipRow) ? "skipped" : "error"
376
+ # 10 hücre: başlık 10 sütun — 11. hücreye düşen skip/error
377
+ # gerekçesi GFM tarafından SESSİZCE düşürülüyordu.
378
+ io&.puts("| #{m.label} | #{status} | - | - | - | - | - | - | - | (#{m.summary}) |")
379
+ next Rubik::Benchmark::MetricResult.new(
380
+ name: "Vakanüvis Tool Adoption — #{m.label}",
381
+ target: "baseline (issue #338)",
382
+ measured: m.summary,
383
+ passed: false
384
+ )
385
+ end
386
+ io&.puts("| #{m.label} | #{m.recall_called ? 'yes' : 'NO'} | #{m.first_recall_turn || '-'} | #{m.recall_calls} | " \
387
+ "#{m.avg_query_terms || '-'} | #{m.keywords_line_used ? 'yes' : 'no'} | #{m.recall_empty_rate || '-'}% | " \
388
+ "#{m.record_called ? 'vakanuvis' : 'none'} | #{m.wrong_path_puts} | #{m.answer_used_recall ? 'yes' : 'no'} |")
389
+ Rubik::Benchmark::MetricResult.new(
390
+ name: "Vakanüvis Tool Adoption — #{m.label}",
391
+ target: "baseline (issue #338)",
392
+ measured: m.summary,
393
+ passed: true
394
+ )
395
+ end
396
+ end
397
+
398
+ # Replay script'leri: her adım {call: [name, args]} | {call_each: [[name,
399
+ # args], ...]} | {answer: "..."} — ReplayDriver bunları GERÇEK
400
+ # araçlarla sırayla koşturur; yanıtlar profilin davranış taahhüdüdür.
401
+ SCRIPTS = {
402
+ "adopter" => {
403
+ "recall-tr-en" => [
404
+ { call: ["vakanuvis", { "action" => "recall", "query" => "gateway retry backoff 429" }] },
405
+ { answer: "Üstel geri çekilme: 2s, 4s, 8s — en fazla 5 deneme; 30 ardışık hata sonra devre kesici açılır." }
406
+ ],
407
+ "durable-conclusion" => [
408
+ { call: ["vakanuvis", { "action" => "record", "topic" => "digest gate dedupe",
409
+ "body" => "Same-target digest jobs are deduplicated; queue cap is 8 jobs, one in-flight per target.\n" \
410
+ "Keywords: digest dedupe gate queue target" }] },
411
+ { answer: "Recorded as a memory card via the chronicler." }
412
+ ]
413
+ },
414
+ "wrong-path" => {
415
+ "recall-tr-en" => [
416
+ { answer: "Bilmiyorum; geçidi yeniden dene ve sonra tekrar sor." }
417
+ ],
418
+ "durable-conclusion" => [
419
+ { call_each: [["storage", { "action" => "put", "type" => "memory", "title" => "digest dedupe",
420
+ "body" => "Same-target digest jobs are deduplicated." }]] },
421
+ { answer: "Persisted directly to storage." }
422
+ ]
423
+ },
424
+ "struggler" => {
425
+ "recall-tr-en" => [
426
+ { call: ["vakanuvis", { "action" => "recall", "query" => "gateway hangi durumlarda üstel bekleme uygular ve devre kesici kaç hata sonra açılır" }] },
427
+ { call: ["vakanuvis", { "action" => "recall", "query" => "gateway retry backoff" }] },
428
+ { answer: "2s, 4s, 8s üstel geri çekilme uygulanır (en fazla 5 deneme)." }
429
+ ],
430
+ "durable-conclusion" => [
431
+ { call: ["vakanuvis", { "action" => "record", "topic" => "digest gate dedupe",
432
+ "body" => "Same-target digest jobs are deduplicated." }] },
433
+ { answer: "Saved via vakanuvis record." }
434
+ ]
435
+ }
436
+ }.freeze
437
+ end
438
+ end
439
+ end
data/lib/rubik/log.rb CHANGED
@@ -6,9 +6,10 @@
6
6
  # follows the repo's [rubik] prefix convention. Emit is error-isolated: a
7
7
  # logger must never raise, least of all from inside a rescue body.
8
8
  #
9
- # warn/error always print (current behavior). debug/info are filtered by
10
- # RUBIK_LOG_LEVEL (default "warn"); the RUBIK_DEBUG-gated debug_warn stays a
11
- # separate mechanism and is NOT migrated by this slice.
9
+ # warn/error always print at the DEFAULT level. debug/info/warn are all
10
+ # filtered by RUBIK_LOG_LEVEL (default "warn") — an operator running at
11
+ # "error" also silences :warn, by design. The RUBIK_DEBUG-gated debug_warn
12
+ # stays a separate mechanism and is NOT migrated by this slice.
12
13
  module Rubik
13
14
  module Log
14
15
  LEVELS = { debug: 0, info: 1, warn: 2, error: 3 }.freeze
@@ -176,9 +176,9 @@ module Rubik
176
176
  end
177
177
 
178
178
  def normalize_raw_output(raw_output, tool_name, call_id, dur)
179
- max_chars = @deps.shell&.config&.fetch("tools.max_output_chars", 12_000) || 12_000
180
- max_lines = @deps.shell&.config&.fetch("tools.max_output_lines", 300) || 300
181
- trunc_strat = @deps.shell&.config&.fetch("tools.truncation_strategy", "head_tail") || "head_tail"
179
+ max_chars = @deps.shell&.config&.fetch("tools.max_output_chars", Rubik::Tools::Truncator::DEFAULT_MAX_CHARS) || Rubik::Tools::Truncator::DEFAULT_MAX_CHARS
180
+ max_lines = @deps.shell&.config&.fetch("tools.max_output_lines", Rubik::Tools::Truncator::DEFAULT_MAX_LINES) || Rubik::Tools::Truncator::DEFAULT_MAX_LINES
181
+ trunc_strat = Rubik::Tools::Truncator.resolve_strategy(@deps.shell&.config&.fetch("tools.truncation_strategy", Rubik::Tools::Truncator::DEFAULT_STRATEGY))
182
182
 
183
183
  if raw_output.is_a?(Rubik::Tools::Result)
184
184
  trunc_res = Rubik::Tools::Truncator.truncate(raw_output.output, max_chars: max_chars, max_lines: max_lines, strategy: trunc_strat)
@@ -17,41 +17,61 @@ module Rubik
17
17
  return error_result(name, "Tool '#{name}' is not available in subagent toolset.") unless tool
18
18
 
19
19
  raw = tool.execute(args)
20
- max_chars = (@config.respond_to?(:fetch) ? @config.fetch("tools.max_output_chars", 12_000) : 12_000) || 12_000
21
- max_lines = (@config.respond_to?(:fetch) ? @config.fetch("tools.max_output_lines", 300) : 300) || 300
22
- normalize_tool_result(raw, name, max_chars: max_chars.to_i, max_lines: max_lines.to_i)
20
+ cfg = @config.respond_to?(:fetch) ? @config : nil
21
+ max_chars = (cfg&.fetch("tools.max_output_chars", Rubik::Tools::Truncator::DEFAULT_MAX_CHARS) || Rubik::Tools::Truncator::DEFAULT_MAX_CHARS).to_i
22
+ max_lines = (cfg&.fetch("tools.max_output_lines", Rubik::Tools::Truncator::DEFAULT_MAX_LINES) || Rubik::Tools::Truncator::DEFAULT_MAX_LINES).to_i
23
+ strategy = Rubik::Tools::Truncator.resolve_strategy(cfg&.fetch("tools.truncation_strategy", Rubik::Tools::Truncator::DEFAULT_STRATEGY))
24
+ normalize_tool_result(raw, name, max_chars: max_chars, max_lines: max_lines, strategy: strategy)
23
25
  rescue StandardError => e
24
26
  error_result(name, "Tool execution error: #{e.class}: #{e.message}", error: e.message)
25
27
  end
26
28
 
27
29
  private
28
30
 
29
- def normalize_tool_result(raw, name, max_chars:, max_lines:)
31
+ def normalize_tool_result(raw, name, max_chars:, max_lines:, strategy: Rubik::Tools::Truncator::DEFAULT_STRATEGY)
30
32
  if raw.is_a?(Rubik::Tools::Result)
31
- trunc = Rubik::Tools::Truncator.truncate(raw.output, max_chars: max_chars, max_lines: max_lines)
33
+ trunc = Rubik::Tools::Truncator.truncate(raw.output, max_chars: max_chars, max_lines: max_lines, strategy: strategy)
32
34
  # Truncation only shortens the display text — status, exit code,
33
35
  # metadata and the original truncation flag all survive it.
36
+ first_line = raw.output.to_s.lines.first.to_s.strip
34
37
  Rubik::Tools::Result.new(tool_name: raw.tool_name.to_s.empty? ? name : raw.tool_name,
35
- status: raw.status, error: raw.error, exit_code: raw.exit_code,
38
+ status: raw.status,
39
+ error: raw.error || (raw.error? ? first_line : nil), exit_code: raw.exit_code,
36
40
  output: trunc[:text], duration_ms: raw.duration_ms,
37
41
  truncated: raw.truncated || trunc[:truncated], metadata: raw.metadata)
38
42
  elsif raw.respond_to?(:status) && raw.respond_to?(:output)
39
43
  out_str = raw.output.to_s
40
- trunc = Rubik::Tools::Truncator.truncate(out_str, max_chars: max_chars, max_lines: max_lines)
41
- Rubik::Tools::Result.new(tool_name: name, status: raw.status.to_sym, output: trunc[:text],
42
- truncated: trunc[:truncated],
44
+ trunc = Rubik::Tools::Truncator.truncate(out_str, max_chars: max_chars, max_lines: max_lines, strategy: strategy)
45
+ first_line = out_str.lines.first.to_s.strip
46
+ status = raw.status.to_sym
47
+ Rubik::Tools::Result.new(tool_name: name, status: status,
48
+ error: (raw.respond_to?(:error) && raw.error) || (status == :error ? first_line : nil),
49
+ output: trunc[:text], truncated: trunc[:truncated],
43
50
  exit_code: raw.respond_to?(:exit_code) ? raw.exit_code : nil,
44
51
  metadata: raw.respond_to?(:metadata) ? raw.metadata : {})
45
52
  else
46
53
  out_str = raw.to_s
47
- trunc = Rubik::Tools::Truncator.truncate(out_str, max_chars: max_chars, max_lines: max_lines)
48
- status = raw.respond_to?(:error?) && raw.error? ? :error : :ok
54
+ trunc = Rubik::Tools::Truncator.truncate(out_str, max_chars: max_chars, max_lines: max_lines, strategy: strategy)
55
+ status = classify_raw_string(raw, out_str)
49
56
  Rubik::Tools::Result.new(tool_name: name, status: status,
50
57
  error: status == :error ? out_str.lines.first.to_s.strip : nil,
51
58
  output: trunc[:text], truncated: trunc[:truncated])
52
59
  end
53
60
  end
54
61
 
62
+ # Root-parity string classification (arch scan card 5, review F1): the
63
+ # same lexicon as StepExecutor#normalize_raw_output's else branch —
64
+ # error?/failed? predicates, then the shared ERROR_PREFIXES table.
65
+ # Without this, a bare "Error: ..." string reads status=ok in subagent
66
+ # history while the root plane honestly reports :error.
67
+ def classify_raw_string(raw, out_str)
68
+ return :error if raw.respond_to?(:error?) && raw.error?
69
+ return :error if raw.respond_to?(:failed?) && raw.failed?
70
+ return :error if raw.is_a?(String) && Rubik::Orchestrator::StepExecutor::ERROR_PREFIXES.any? { |pfx| out_str.start_with?(pfx) }
71
+
72
+ :ok
73
+ end
74
+
55
75
  def error_result(name, text, error: nil)
56
76
  Rubik::Tools::Result.new(tool_name: name, status: :error, output: text, error: error || text)
57
77
  end
@@ -52,9 +52,13 @@ module Rubik
52
52
  # "head_tail" (default, original behavior) keeps head + tail around the
53
53
  # omission notice; "head_only" keeps the message head — useful when the
54
54
  # actionable guidance at the tail must not crowd out the error header.
55
- # Truncator normalizes strings/symbols and falls back to head_tail on
56
- # unknown values.
57
- strategy = config["emergency_truncate_strategy"] || config[:emergency_truncate_strategy] || "head_tail"
55
+ # Truncator.resolve_strategy normalizes strings/symbols, falls back to
56
+ # head_tail on unknown values AND warns once — the same typo-honesty
57
+ # contract as the tools.truncation_strategy knob (issue #349).
58
+ strategy = Rubik::Tools::Truncator.resolve_strategy(
59
+ config["emergency_truncate_strategy"] || config[:emergency_truncate_strategy],
60
+ key: "context.emergency_truncate_strategy"
61
+ )
58
62
 
59
63
  history.each do |msg|
60
64
  next unless ConversationRows.row?(msg)
@@ -270,6 +270,13 @@ module Rubik
270
270
  private_class_method def self.suspend_goal_turn(ctx, orchestrator:, goal_state:, goal_ledger:,
271
271
  goal_text:, goal_id:, record_pair:, reason:, tokens: nil)
272
272
  attempt = suspended_attempt(ctx, goal_id) + 1
273
+ # Ledger kimliği askıdan ÖNCE okunur: suspend! → ledger.suspend!
274
+ # sırası metanın bu turun token'larını içermesi için zorunlu,
275
+ # ama o damga storage_id'yi değiştirdiği için sonradan atanan id
276
+ # SUSPEND_EVENT'e geç kalırdı — ilk askıda kronik satırı slug
277
+ # kimliğine düşer, ledger= bağlantısı kaybolurdu.
278
+ # resume_cross_process ile aynı uçuş-içi atama önceliği.
279
+ goal_state.ledger_id = goal_ledger.snapshot[:storage_id] if goal_ledger
273
280
  goal_state.suspend!(reason, tokens: tokens)
274
281
  goal_ledger&.suspend!(suspend_stamp_meta(goal_state, goal_id, reason, attempt))
275
282
  registry = goal_registry_for(ctx)
@@ -82,7 +82,11 @@ module Rubik
82
82
  clean_raw = Rubik::Security::Redactor.redact(parsed.raw)
83
83
  @recorder&.record_repl_command(clean_raw, output_text)
84
84
  # Plan 61 D-8: bound what a bang command leaves in LLM memory; the recorder keeps the full text.
85
- trunc = Rubik::Tools::Truncator.truncate(output_text, max_chars: @config.fetch("tools.max_output_chars", 12_000), strategy: :head_tail)
85
+ max_chars = @config.fetch("tools.max_output_chars", Rubik::Tools::Truncator::DEFAULT_MAX_CHARS)
86
+ # Deliberately context-scoped (issue #349 F8): truncation_strategy is a
87
+ # tool-output contract (the two orchestrator planes); bang-command echo
88
+ # is REPL history capture and stays head_tail, outside the knob's promise.
89
+ trunc = Rubik::Tools::Truncator.truncate(output_text, max_chars: max_chars, strategy: :head_tail)
86
90
  @conversation_history << { role: "user", content: clean_raw.delete_prefix("!") } # bangs stay out of LLM history; recorder keeps the literal
87
91
  @conversation_history << { role: "assistant", content: trunc[:truncated] ? trunc[:text] : output_text }
88
92
  trim_conversation_window!
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "digest"
4
+
3
5
  module Rubik
4
6
  module Storage
5
7
  module Vakanuvis
@@ -35,6 +37,44 @@ module Rubik
35
37
  [["goal.#{payload[:goal_ordinal]}.#{state}", "#{head} (event=goal.#{payload[:goal_ordinal]}.#{state})"]]
36
38
  end
37
39
 
40
+ # Askı satırı (issue #350): anahtar PEEK sırası DEĞİL hedef
41
+ # kimliğidir. Askı peek'lediği sıraya ait görünür; araya başka bir
42
+ # goal bitişi girerse peek yanlış kayda bağlanır, iki askı aynı
43
+ # peek'i paylaşınca dedup ikinci satırı yutar. Kimlik = ledger_id
44
+ # (goal.ledger açıkken post_lifecycle'nin damgası), yoksa goal
45
+ # metninin slug'ı; steps + hata özeti anahtara işlenir — gerçek
46
+ # yeniden teslim düşer, içerik değişen askı kalır. Satır goal=
47
+ # tek-satırını da taşır: sıra bağlantısı kalkınca metnin kendisi
48
+ # dürüst bağdır (goal kaydı askıda henüz mint edilmez).
49
+ def suspend_line(seq, payload)
50
+ ledger = payload[:ledger_id].to_s
51
+ identity = ledger.empty? ? Vocabulary.slug(payload[:goal]) : ledger
52
+ head = "- [#{seq}] goal status=suspended steps=#{payload[:steps]}/#{payload[:max_steps]}"
53
+ head += " error=#{one_line(payload[:last_error], 80)}" unless payload[:last_error].to_s.empty?
54
+ head += " ledger=#{one_line(ledger, 40)}" unless ledger.empty?
55
+ head += " goal=#{one_line(payload[:goal], 60)}"
56
+ error = payload[:last_error].to_s
57
+ err_part = error.empty? ? "ok" : Digest::SHA256.hexdigest(error)[0, 8]
58
+ key = "goal.suspend.#{identity}.#{payload[:steps]}.#{err_part}"
59
+ [[key, "#{head} (event=#{key})"]]
60
+ end
61
+
62
+ # Olay-kimliği eki (issue #351 F7): handle_event'in kurtarma
63
+ # günlüğü tek başına kind ile askı satırını bitiş satırından
64
+ # ayırt edemez — düşen kronik satırının kimliği status + goal
65
+ # tek-satırıdır (bitiş ayrıca maintain(goal_end: true)'i atlar).
66
+ # Üretilemezse boş dizgi; günlük satırı asla patlamaz.
67
+ def event_context(kind, payload)
68
+ return "" unless payload.is_a?(Hash)
69
+
70
+ case kind
71
+ when :goal then " status=#{payload[:status]} goal=#{one_line(payload[:goal], 40)}"
72
+ when :subagent then " id=#{payload[:storage_id] || payload[:id]} state=#{payload[:state]}"
73
+ when :turn then " kind=#{payload[:kind]} prompt=#{one_line(payload[:prompt], 40)}"
74
+ else ""
75
+ end
76
+ end
77
+
38
78
  # Kök-turn satırı (root-turn planı 2026-10-04): REPL turn'ünün
39
79
  # deterministik özeti. Boş alan (tools/tokens) satırda görünmez;
40
80
  # ordinal harness-lokal sayaçtan gelir, anahtar turn.<n>.<kind>.
@@ -64,13 +104,20 @@ module Rubik
64
104
  "goal: #{text}"
65
105
  end
66
106
 
67
- # Satır ekleme + tekilleştirme: body'de anahtarı olan satır bir
68
- # daha yazılmaz (olay yeniden teslimi — resume sonrası ikinci
69
- # tamamlanma gibi — yinelenen satır açmaz).
107
+ # Satır ekleme + tekilleştirme: body'de SATIR SONU (event=anahtar)
108
+ # taşıyan satır bir daha yazılmaz (olay yeniden teslimi — resume
109
+ # sonrası ikinci tamamlanma gibi — yinelenen satır açmaz). Bağ
110
+ # (end_with?) zorunludur: serbest metin alanları (goal=, error=,
111
+ # prompt= …) bir "(event=X)" dizgisi TAŞIYABİLİR; gövde-geniş
112
+ # include taraması o dizgiyi sahte anahtar sayıp gelecekteki
113
+ # gerçek X satırını sessizce yutardı (review #353: zehirlenmiş
114
+ # dedup). Anahtar daima satırı bitirdiğinden bağ tarama hem dürüst
115
+ # hem ucuzdur.
70
116
  def append(store, ref:, title:, tags: Vocabulary::ANNAL_TAGS, links: {}, lines: [])
71
117
  existing = store.get(type: "note", id: ref)
72
118
  body = existing ? existing["body"].to_s : ""
73
- fresh = lines.reject { |key, _line| body.include?("(event=#{key})") }
119
+ settled = body.lines.map(&:rstrip)
120
+ fresh = lines.reject { |key, _line| settled.any? { |l| l.end_with?("(event=#{key})") } }
74
121
  return existing if existing && fresh.empty?
75
122
 
76
123
  merged = ([body] + fresh.map { |_key, line| line }).reject { |l| l.to_s.strip.empty? }.join("\n")
@@ -25,11 +25,13 @@ module Rubik
25
25
  # v2 §4-V6: öneri sonundaki smoke-test sorgularının sayı sınırı.
26
26
  CHECK_LIMIT = 5
27
27
 
28
- def receipt(query:, target:, spawned:)
28
+ def receipt(query:, target:, spawned:, spawn_failed: false)
29
29
  head = "Digest queued (async): the chronicler will compile '#{AnnalWriter.one_line(query, 80)}' into '#{target}'."
30
30
  tail = if spawned
31
31
  "This call did NOT wait for the LLM — conclude your turn; recall it later with " \
32
32
  "vakanuvis(action: 'recall', query: '...') once compiled."
33
+ elsif spawn_failed
34
+ "The distiller spawn attempt failed (see dmesg) — the job stays queued and the spawn retries with the next digest or flush."
33
35
  else
34
36
  "No distiller run started yet (subagents.vakanuvis.distiller or the chronicler type may be " \
35
37
  "disabled) — the job stays queued and compiles when the distiller becomes available."
@@ -37,6 +39,16 @@ module Rubik
37
39
  "#{head} #{tail}"
38
40
  end
39
41
 
42
+ def duplicate_receipt(query, target)
43
+ "Digest already queued for '#{target}' (#{AnnalWriter.one_line(query, 60)}): the chronicler is " \
44
+ "compiling it — recall it later with vakanuvis(action: 'recall', query: '...')."
45
+ end
46
+
47
+ def full_receipt(target, capacity)
48
+ "Digest not accepted for '#{target}': capacity #{capacity} is full. " \
49
+ "Existing jobs are preserved; retry after a digest finishes."
50
+ end
51
+
40
52
  # Öneri şeması: ilk satır hedef ref (wiki.<slug>), gerisi gövde;
41
53
  # gövde sonunda isteğe bağlı `CHECK: <sorgu>` satırları (v2 §4-V6
42
54
  # smoke testi — en fazla CHECK_LIMIT adet, gövdeden AYIKLANIR,
@@ -128,7 +140,7 @@ module Rubik
128
140
  "read_file(path: '#{annal_uri}')"
129
141
  end
130
142
  Array(job[:transcript_uris]).each { |u| sources << "- Transcript: read_file(path: '#{u}')" }
131
- sources << "- Goal record: read_file(path: '#{store.uri_for('goal', job[:goal_id])}')" if job[:goal_id]
143
+ sources << "- Goal record: read_file(path: '#{store.uri_for('note', Vocabulary.goal_ref(job[:goal_ordinal]))}')" if job[:goal_ordinal]
132
144
  if (gaps = open_recall_gaps(store))
133
145
  sources << gaps
134
146
  end
@@ -58,7 +58,9 @@ module Rubik
58
58
  # submit_with_status aynı kabulü [makbuz, durum] ikilisi olarak
59
59
  # verir (:accepted|:duplicate|:full) — otomatik damıtım red'i
60
60
  # makbuz dizgesinden ayırt edebilsin diye (review #346).
61
- def submit_with_status(query, spawn: true)
61
+ # goal_ordinal: goal-end auto-digest'in kanıt hedefi (tarama kartı 1,
62
+ # 2026-11-01) — işe işlenir, brief goal kaydını kaynak gösterir.
63
+ def submit_with_status(query, spawn: true, goal_ordinal: nil)
62
64
  target = Vocabulary.wiki_ref(query)
63
65
  # v2 §4-V5: aynı hedefli iş zaten kuyrukta ya da uçuştaysa makbuz
64
66
  # 'already queued' varyantına düşer — çift doğum yok.
@@ -66,21 +68,18 @@ module Rubik
66
68
  next :duplicate if @digest_targets.key?(target)
67
69
  next :full if @digest_targets.size >= @max_digest_jobs
68
70
 
69
- @pending_digests << { query: query, target: target, transcript_uris: source_transcript_uris }
71
+ @pending_digests << Job.new(query: query, target: target, transcript_uris: source_transcript_uris, goal_ordinal: goal_ordinal)
70
72
  @digest_targets[target] = true
71
73
  :accepted
72
74
  end
73
- if admission == :duplicate
74
- return ["Digest already queued for '#{target}' (#{AnnalWriter.one_line(query, 60)}): the chronicler is " \
75
- "compiling it — recall it later with vakanuvis(action: 'recall', query: '...').", admission]
76
- end
77
- if admission == :full
78
- return ["Digest not accepted for '#{target}': capacity #{@max_digest_jobs} is full. " \
79
- "Existing jobs are preserved; retry after a digest finishes.", admission]
80
- end
75
+ return [DistillGate.duplicate_receipt(query, target), admission] if admission == :duplicate
76
+
77
+ return [DistillGate.full_receipt(target, @max_digest_jobs), admission] if admission == :full
81
78
 
82
79
  spawned = spawn ? spawn_job!(batch: false) : nil
83
- [DistillGate.receipt(query: query, target: target, spawned: !spawned.nil?), admission]
80
+ # REVIEW: patlak doğum "disabled" makbuzuyla yalanlanmasın (F2).
81
+ failed_spawn = spawn && spawned.nil? && spawn_allowed?
82
+ [DistillGate.receipt(query: query, target: target, spawned: !spawned.nil?, spawn_failed: failed_spawn), admission]
84
83
  end
85
84
 
86
85
  def submit(query, spawn: true) = submit_with_status(query, spawn: spawn)[0]
@@ -150,7 +149,7 @@ module Rubik
150
149
  # Salt-okunuma peeki (test/kurcalama yüzeyi): kuyruğun başını
151
150
  # gösterir — tüketmez. Üretim görünürlüğü aşağıdaki job_targets'tır.
152
151
  def peek_job(batch: false)
153
- @lock.synchronize { @pending_digests.first || (batch ? { batch: true } : nil) }
152
+ @lock.synchronize { @pending_digests.first || (batch ? Job.new(batch: true) : nil) }
154
153
  end
155
154
 
156
155
  # İş defteri görünümü (issue #100 P2b): kuyruktaki + uçuştaki
@@ -219,11 +218,15 @@ module Rubik
219
218
  @supervisor.respond_to?(:spawn) &&
220
219
  @supervisor.respond_to?(:type_registry) && @supervisor.type_registry.get(TYPE_NAME)
221
220
  end
221
+ # Genel durum sorgusu (inceleme turu 2): Harness, kabul edilen
222
+ # digest topic'lerinin hiç derlenemeyeceğini dürüstçe
223
+ # bildirmek için doğum kapısını sorabilir olmalı.
224
+ public :spawn_allowed?
222
225
 
223
226
  # Aşağısı YALNIZ @lock altından çağrılır (çağıran: submit /
224
227
  # spawn_job! / completion_event) — kendi kilidi almaz.
225
228
  def claim_job(batch: false)
226
- @pending_digests.shift || (batch ? { batch: true, transcript_uris: source_transcript_uris } : nil)
229
+ @pending_digests.shift || (batch ? Job.new(batch: true, transcript_uris: source_transcript_uris) : nil)
227
230
  end
228
231
 
229
232
  def restore_job(job)
@@ -27,6 +27,9 @@ module Rubik
27
27
  SUBAGENT_EVENT = :subagent_completed
28
28
  GOAL_EVENT = :goal_finished
29
29
  TURN_EVENT = :turn_finished
30
+ # Askı kroniği (tarama kartı 2, 2026-11-01): GoalState::SUSPEND_EVENT — abonelik
31
+ # :goal akışına yazar; worker status=="suspended" ile ayırt eder.
32
+ SUSPEND_EVENT = :goal_suspended
30
33
 
31
34
  attr_reader :store, :config, :supervisor, :dmesg, :session_slug, :gate
32
35
 
@@ -269,6 +272,7 @@ module Rubik
269
272
 
270
273
  @event_bus.subscribe(SUBAGENT_EVENT, context: @context) { |p| enqueue_event(:subagent, p) }
271
274
  @event_bus.subscribe(GOAL_EVENT, context: @context) { |p| enqueue_event(:goal, p) }
275
+ @event_bus.subscribe(SUSPEND_EVENT, context: @context) { |p| enqueue_event(:goal, p) }
272
276
  @event_bus.subscribe(TURN_EVENT, context: @context) { |p| enqueue_event(:turn, p) } if @root_turns
273
277
  end
274
278
 
@@ -295,13 +299,19 @@ module Rubik
295
299
  break if kind == STOP
296
300
 
297
301
  handle_event(kind, payload)
302
+ rescue StandardError => e
303
+ # Last-ditch net: a raise escaping handle_event's own rescue
304
+ # would kill the worker with queued events unconsumed — and
305
+ # shutdown's alive?-check stays blind to that loss.
306
+ Vakanuvis.log_via(@dmesg, :warn, "chronicler worker event dispatch failed (#{e.class}: #{e.message}) — worker continues")
298
307
  ensure
299
308
  @monitor.synchronize { @busy -= 1 }
300
309
  end
301
310
  end
302
311
 
303
312
  # TranscriptIndexer disiplini: kronikçinin kendi hatası akışı
304
- # asla bozmaz — uyarı düşer, sonraki olay işlenir.
313
+ # asla bozmaz — uyarı düşer, sonraki olay işlenir. Kimlik eki
314
+ # (F7) AnnalWriter.event_context'te.
305
315
  def handle_event(kind, payload)
306
316
  case kind
307
317
  when :subagent then handle_subagent(payload)
@@ -309,7 +319,9 @@ module Rubik
309
319
  when :turn then handle_turn(payload)
310
320
  end
311
321
  rescue StandardError => e
312
- Vakanuvis.log_via(@dmesg, :warn, "event handling failed (#{kind}): #{e.class}: #{e.message}")
322
+ # İlk backtrace karesi (event_loop.rb kuralı): depo hatasını
323
+ # annal/digest hatasından ayırt eder.
324
+ Vakanuvis.log_via(@dmesg, :warn, "event handling failed (#{kind}#{AnnalWriter.event_context(kind, payload)}): #{e.class}: #{e.message} @ #{(e.backtrace || []).first}")
313
325
  end
314
326
 
315
327
  def handle_subagent(payload)
@@ -336,6 +348,10 @@ module Rubik
336
348
  end
337
349
 
338
350
  def handle_goal(payload)
351
+ if payload[:status].to_s == "suspended" # askı kroniği: satır düşer, mint/upsert/maintain yok (kart 2)
352
+ seq = next_seq # anahtar hedef kimliği — sıra peek'i değil (issue #350)
353
+ return append_session(AnnalWriter.suspend_line(seq, payload))
354
+ end
339
355
  ordinal = next_goal_ordinal
340
356
  enriched = payload.merge(goal_ordinal: ordinal)
341
357
  seq = next_seq
@@ -344,17 +360,16 @@ module Rubik
344
360
  title: AnnalWriter.goal_title(ordinal, enriched[:goal]), body: goal_body.join("\n"),
345
361
  tags: Vocabulary::ANNAL_TAGS + [Vocabulary::GOAL_TAG], links: { "session" => Vocabulary.session_ref(@session_slug) })
346
362
  append_session(AnnalWriter.goal_lines(seq, enriched))
347
- maintain(goal_end: true)
363
+ maintain(goal_end: true, goal_ordinal: ordinal)
348
364
  end
349
365
 
350
366
  # Goal sırası mağazadan türetilir (refs'teki en büyük annal.goal.<n>
351
- # + 1): süreç yeniden başlasa bile aynı depoda numara çakışmaz —
352
- # determinizm depo durumundan, saatlerden değil.
353
- def next_goal_ordinal
354
- @monitor.synchronize do
355
- max_ref = @store.refs.keys.filter_map { |k| Vocabulary.goal_ordinal(k) }.max.to_i
356
- @goal_ordinal = [@goal_ordinal, max_ref].max + 1
357
- end
367
+ # + 1): süreç yeniden başlasa bile aynı depoda numara çakışmaz.
368
+ # Askı artık peek ETMEZ (issue #350) — sıra yalnız bitişte mint edilir.
369
+ def next_goal_ordinal = @goal_ordinal = peek_goal_ordinal
370
+
371
+ def peek_goal_ordinal
372
+ @monitor.synchronize { [@goal_ordinal, @store.refs.keys.filter_map { |k| Vocabulary.goal_ordinal(k) }.max.to_i].max + 1 }
358
373
  end
359
374
 
360
375
  # Flush sayacı (R8): subagent ve turn olayları aynı ölçüde sayılır;
@@ -372,13 +387,13 @@ module Rubik
372
387
  # yeniden hesap + dokunulan hafıza konularının otomatik damıtımı +
373
388
  # damıtıcı doğum kararı. v2 §4-V5: toplu doğum yalnızca kirli işaretle;
374
389
  # hedefli damıtım TÜM konuları kuyrukladıysa toplu doğum atlanır — kayıp/red varsa batch telafi eder.
375
- def maintain(goal_end: false)
390
+ def maintain(goal_end: false, goal_ordinal: nil)
376
391
  # Sweep record() ile aynı kilide koşar (tek-yazar disiplini):
377
392
  # get-then-put dansı çift satır açamaz.
378
393
  swept = @monitor.synchronize { MemorySteward.sweep_rogue_memory(@store) }
379
394
  Vakanuvis.log_via(@dmesg, :info, "steward swept #{swept} rogue memory row(s)") if swept.positive?
380
395
  WikiSkeleton.rebuild_home(@store)
381
- digested = auto_digest_touched!(spawn: !stopped?) if goal_end
396
+ digested = auto_digest_touched!(spawn: !stopped?, goal_ordinal: goal_ordinal) if goal_end
382
397
  @gate.flush_tick unless stopped? || digested
383
398
  end
384
399
 
@@ -387,7 +402,7 @@ module Rubik
387
402
  # spawn=false (kapanış) zombi LLM doğumunu engeller. Konu-başı yalıtım
388
403
  # (review #346): tek başarısız/red'li submit kalan konuları kaybettirmez —
389
404
  # kayıplar @touched_topics'e geri alınır, bir sonraki flush noktası dener.
390
- def auto_digest_touched!(spawn: true)
405
+ def auto_digest_touched!(spawn: true, goal_ordinal: nil)
391
406
  return false unless @auto_digest && @distiller_enabled
392
407
 
393
408
  spawn_allowed = spawn && !stopped?
@@ -395,10 +410,11 @@ module Rubik
395
410
  return false if topics.empty?
396
411
 
397
412
  stranded = topics.reject do |topic|
398
- _receipt, status = @gate.submit_with_status(topic, spawn: spawn_allowed)
413
+ _receipt, status = @gate.submit_with_status(topic, spawn: spawn_allowed, goal_ordinal: goal_ordinal)
414
+ next true if status == :duplicate && Vakanuvis.log_duplicate_digest(@dmesg, topic, goal_ordinal) # F9: sessiz başarı değil
399
415
  next true unless status == :full
400
416
 
401
- Vakanuvis.log_via(@dmesg, :warn, "auto-digest refused for '#{topic}' (gate capacity full) — kept for the next flush point")
417
+ Vakanuvis.log_via(@dmesg, :warn, "auto-digest refused for '#{topic}' (gate capacity full) — kept for the next goal end or shutdown")
402
418
  false
403
419
  rescue StandardError => e
404
420
  Vakanuvis.log_via(@dmesg, :warn, "auto-digest submit failed for '#{topic}' (#{e.class}: #{e.message})")
@@ -411,6 +427,15 @@ module Rubik
411
427
  note += "; #{stranded.size} refused/failed topic(s) kept for the next flush point (warnings above)" unless stranded.empty?
412
428
  Vakanuvis.log_via(@dmesg, :warn, note)
413
429
  end
430
+ # Kapalı doğum: kapı :accepted dedi ama damıtıcı hiç doğamaz —
431
+ # uyarı yoksa topic'ler tüm oturum kuyrukta sessizce birikirdi.
432
+ if spawn_allowed && @gate.respond_to?(:spawn_allowed?) && !@gate.spawn_allowed?
433
+ queued = topics - stranded
434
+ unless queued.empty?
435
+ note = "auto-digest queued #{queued.size} touched topic(s) but no distiller can run (spawn disabled)"
436
+ Vakanuvis.log_via(@dmesg, :warn, "#{note} — they will not compile this session: #{queued.join(', ')}")
437
+ end
438
+ end
414
439
  stranded.empty?
415
440
  end
416
441
 
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubik
4
+ module Storage
5
+ module Vakanuvis
6
+ module DistillGate
7
+ # Digest iş sözleşmesinin tek evi (tarama kartı 1, 2026-11-01 — defer #9
8
+ # kapanışı): kuyrukta iki ayrı Hash literal mint ediliyor, tüketiciler
9
+ # dört anahtarı [] ile okuyor ve öksüz :goal_id hiçbir mint sitesinde
10
+ # yazılmıyordu. Job bu şekli tiper: Struct#[] sembol okumasını korur
11
+ # (mevcut okuma siteleri değişmez), keyword_init mint sitelerini
12
+ # sözlüğe sabitler, var olmayan anahtar imkânsızlaşır.
13
+ #
14
+ # - query: damıtılacak konu sorgusu (toplu işlerde nil)
15
+ # - target: hedef wiki ref (Vocabulary.wiki_ref) — nil ⇒ toplu derleme;
16
+ # nil-target ayırt etme sözleşmesi değişmez, batch onun açık bayrağıdır
17
+ # - transcript_uris: kaynak disiplinine uyan tamamlanmış transcript URI'leri
18
+ # - goal_ordinal: goal-end auto-digest taşıyıcısı (Q2, 2026-11-01) — brief
19
+ # goal kaydını (annal.goal.<n> note'u) kaynak listesine ekler;
20
+ # kapanış ve toplu işlerde nil, satır brief'e hiç girmez
21
+ Job = Struct.new(:query, :target, :transcript_uris, :batch, :goal_ordinal, keyword_init: true)
22
+ end
23
+ end
24
+ end
25
+ end
@@ -69,6 +69,17 @@ module Rubik
69
69
  existing["links"] == links.to_h { |k, v| [k.to_s, v.to_s] }.sort.to_h &&
70
70
  existing["status"] == status.to_s
71
71
  end
72
+
73
+ # Mükerrer otomatik-damıtım kabulü (issue #351 F9): "already queued"
74
+ # sessiz başarı sayılmaz — mevcut iş değişmez, bu goal kaydına
75
+ # bağlanmaz; iz satırı düşer. Dönen true çağrı yerindeki kısa-devre
76
+ # koşuluna hizmet eder (mükerrer stranded DEĞİLDİR, yeniden kuyruğa
77
+ # girmez — var olan iş hedefi zaten derliyor).
78
+ def log_duplicate_digest(dmesg, topic, goal_ordinal)
79
+ note = goal_ordinal ? "goal record ##{goal_ordinal} not attached; " : ""
80
+ log_via(dmesg, :warn, "auto-digest duplicate for '#{topic}' — #{note}existing job keeps its own context")
81
+ true
82
+ end
72
83
  end
73
84
  end
74
85
  end
@@ -78,6 +89,7 @@ require_relative "vakanuvis/annal_writer"
78
89
  require_relative "vakanuvis/memory_steward"
79
90
  require_relative "vakanuvis/wiki_skeleton"
80
91
  require_relative "vakanuvis/distill_gate"
92
+ require_relative "vakanuvis/job"
81
93
  require_relative "vakanuvis/gate"
82
94
  require_relative "vakanuvis/harness"
83
95
  require_relative "vakanuvis/orientation"
@@ -5,6 +5,59 @@ module Rubik
5
5
  module Truncator
6
6
  DEFAULT_MAX_CHARS = 12_000
7
7
  DEFAULT_MAX_LINES = 300
8
+ # The strategy knob's default lives here (arch scan card 5, 2026-11-01):
9
+ # both orchestrator planes read tools.truncation_strategy with this
10
+ # fallback — one literal for the whole repo instead of restated copies.
11
+ DEFAULT_STRATEGY = :head_tail
12
+ # Known-strategy set (issue #349): a typo'd config value used to die
13
+ # silently into the head_tail `else` branch in both planes. This set
14
+ # is the honest frontier — resolve_strategy warns against it, and a
15
+ # new strategy added to #truncate must register here to be reachable.
16
+ STRATEGIES = [:head_tail, :head_only, :tail_only].freeze
17
+
18
+ class << self
19
+ # Configured-value resolver for the strategy fetch sites (root
20
+ # StepExecutor + subagent ToolDispatch + prompt EmergencyCompactor).
21
+ # nil/false/empty/missing fall back to the default SILENTLY (that
22
+ # path is correct today); a non-empty value outside STRATEGIES warns
23
+ # ONCE per distinct knob+value pair, then keeps the default — the
24
+ # operator "tuning" truncation with a typo must not get
25
+ # byte-identical old behavior in silence. `key:` names the config
26
+ # knob in the warn so each caller's message points at its own
27
+ # setting, and the frontier dedups on the pair: the same typo under
28
+ # two different knobs must warn twice, or fixing one knob leaves
29
+ # the other misconfigured in silence.
30
+ def resolve_strategy(value, key: "tools.truncation_strategy")
31
+ return DEFAULT_STRATEGY if value.nil? || value == false || value.to_s.strip.empty?
32
+
33
+ sym = value.to_s.downcase.to_sym
34
+ return sym if STRATEGIES.include?(sym)
35
+
36
+ warn_unknown_strategy(value, key)
37
+ DEFAULT_STRATEGY
38
+ end
39
+
40
+ private
41
+
42
+ # One-time-per-knob-and-value warn (UI::Theme#paint precedent):
43
+ # the first bad value speaks, re-occurrences (every tool result
44
+ # would re-resolve) stay quiet. Warn level, not debug: a config
45
+ # typo is operator-visible in normal operation. The value is
46
+ # forced to UTF-8 and scrubbed BEFORE interpolation — scrub alone
47
+ # is a no-op on BINARY strings — because Log.warn's rescue cannot
48
+ # reach a message that fails to build, and a binary-encoding
49
+ # config value must never turn tool results into errors.
50
+ def warn_unknown_strategy(value, key)
51
+ shown = value.to_s.dup.force_encoding(Encoding::UTF_8).scrub
52
+ return unless unknown_strategies.add?([key, shown])
53
+
54
+ Rubik::Log.warn(:tools, "#{key}='#{shown}' is not a known strategy (#{STRATEGIES.join('/')}) — using #{DEFAULT_STRATEGY}")
55
+ end
56
+
57
+ def unknown_strategies
58
+ @unknown_strategies ||= Set.new
59
+ end
60
+ end
8
61
 
9
62
  def self.truncate(text, max_chars: DEFAULT_MAX_CHARS, max_lines: DEFAULT_MAX_LINES, strategy: :head_tail, separator: "\n")
10
63
  str = text.to_s
data/lib/rubik/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rubik
4
- VERSION = "0.5.0"
4
+ VERSION = "0.6.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rubik
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Özgür Ulukır
@@ -267,6 +267,7 @@ files:
267
267
  - lib/rubik/benchmark.rb
268
268
  - lib/rubik/benchmark/runner.rb
269
269
  - lib/rubik/benchmark/synth_repo.rb
270
+ - lib/rubik/benchmark/vakanuvis_adoption.rb
270
271
  - lib/rubik/buffer.rb
271
272
  - lib/rubik/buffer/apply_contract.rb
272
273
  - lib/rubik/buffer/diff.rb
@@ -720,6 +721,7 @@ files:
720
721
  - lib/rubik/storage/vakanuvis/distill_gate.rb
721
722
  - lib/rubik/storage/vakanuvis/gate.rb
722
723
  - lib/rubik/storage/vakanuvis/harness.rb
724
+ - lib/rubik/storage/vakanuvis/job.rb
723
725
  - lib/rubik/storage/vakanuvis/memory_steward.rb
724
726
  - lib/rubik/storage/vakanuvis/orientation.rb
725
727
  - lib/rubik/storage/vakanuvis/service.rb