# Spec: ツール結果の即時圧縮 — 判断専用モデル Jev で、関連の無い段落を返す前に落とす - 起票: 2026-09-22 - 状態: **Done(2026-09-22。rev4 = P4 の実機 5 件で D1 と D6 を狭め、P4 実機記録 3 で門を超える入力が実在することを実測)。** rev2 = 査読 2 系統 19 点 → 採用 14 / 訂正して採用 4 / 反証 1 + 実装の読み 3 点。採否の表は Notes 6。**rev3 で D4b(JSON の包装型)が 増え、未決 2 が「依頼文のまま」で消えた。既定の閾値 0.2 は要否判定で確定した** - 起点: 利用者 —「TypeSafe AI の Jev という、文章を出力しないで判断だけを返す AI が話題になっている。 Kataribe で少し検証した。Fuseforks にもコストカットで使えそうか」→「省略のようなものに効くかな。 ツール結果の即時圧縮などにも」(2026-09-22)。同日に実測 2 本を取ってから起票した (CLAUDE.md「TypeSafe Jev の検討」の節が正。数字は下の「実測」に写した) - 利用者裁定(2026-09-22。起票時の 3 点): 1. **対象は MCP 由来のツールと `rag`。`file` と `run` は対象外**(sqz の教訓 = 圧縮された出力は 編集の材料に使えない、を入れる) 2. **閾値は既定値を持ち、利用者が切り替えられる** 3. **システム設定に Jev の API を設定するページを作り、そこのチェックで圧縮の使用を切り替える** - 利用者裁定(2026-09-22。rev2 の 6 点): 戻る道は A 案(`omitted`)/ 基準は既定を依頼文にして P0 で対で測る / 閾値は 4 値 + 対応表 / 4,000 字はコード定数 / **基準が 20 字未満なら圧縮しない (`no_basis`)** / **20 秒超過は済んだバッチも捨てて全文** - 覆す凍結: 無い。`tool_call_detail_contract`(Spec 57)の「モデルへ返した本文を記録する」は そのまま — 記録するのは**圧縮後**の本文(モデルが実際に受け取ったもの)。 2026-08-17 の「ツール出力の畳み込みは作らない」は**行の形で畳む機構**への結論で、 中身の関連度で落とす本 Spec とは問いが別(あの節に前方参照を入れてある) ## Goal 1. **MCP 由来のツールと `rag` が返した 4,000 字以上の本文から、そのターンの依頼に関係の無い段落を、 モデルへ返す前に落とす。** 落とすのは結果が返った瞬間の 1 回だけで、落とした後の文字列を そのまま積む。以後の周では 1 バイトも変えない(入力キャッシュの前方一致を壊さない)。 **この「1 バイトも変えない」が指すのは本文の不変性**(履歴に積んだツール結果の前方一致)。 `omitted` の提示によるツール一覧の変化は別の層で、**チェックを切り替えた 1 回だけ**起きる 静的な書き換え(毎ターン変わるものは 1 つも増えない) 2. **残る部分は逐語。** 要約も言い換えもしない。切るのは段落の境界だけ 3. **落としたことと、落としたものへ戻る道を、本文に書く**(`failures.md` #44 の規律) 4. **既定は OFF。キーが無ければ機構ごと存在しない。** 使わない村のプロンプト・ツール提示・ ログは 1 バイトも変わらない 5. **Jev が落ちても、遅くても、ターンは止まらない。** そのときは全文をそのまま返す **やらないこと(範囲外)**: 履歴の間引き(キャッシュと衝突する。Notes 1)/ 提示するツールの絞り込み (同)/ `run` の実行前の安全判定(閉じた許容の逆向き。Notes 1)/ 完遂の評価器と手順書の選別 (別 Spec。較正を測ってから)/ `file`・`grep`・`fd`・`sd`・`yq`・`diff`・`blackboard`・`remember`・ `run` の出力の圧縮 / 個体ごとの ON・OFF(裁定 3 はシステム設定の 1 つのチェック。Notes 3)/ Jev の費用を統計画面の `≈ $` へ入れること(Notes 4)/ TypeSafe 直の接続(Cloudflare Workers AI 経由のみ) ## 実測 ### P0(2026-09-22。**こちらが正**。材料は実物の変換・割り方は D4) 材料 10 本 = MCP の fetch と同じ変換(`readabilipy` の Readability.js + `markdownify`)で取った Web ページ 6 本 + `D:\ManualeRAG` の実ファイル 4 本。割り方は D4 の 5 段。`jev-1.13.0`。 **閾値 0.2 で落ちる割合(字数加重で全体 47%)**: | | 落ちる | | 落ちる | |---|---|---|---| | mcp_spec 12K 字 | **96%** | rust_book 13K 字 | 36% | | rag_contract 32K 字 | 80% | rag_design 20K 字 | 34% | | github_readme 5K 字 | 78% | wikipedia 55K 字 | 23% | | rag_b69 37K 字 | 61% | langchain 9K 字 | 21% | | arxiv 120K 字 | 51% | rag_memory 30K 字 | 19% | - **96% 落ちた `mcp_spec` は誤りではなく正解だった** — 12,028 字のうち残った 2 段落が `0.96` の「Servers **MUST** validate the Origin header」と `0.64` の「Without these protections, attackers could use DNS rebinding」で、**依頼(Origin の扱いと DNS rebinding 対策) への答えは完全に保たれている**。落ちたのは stdio・セッション管理・後方互換性・カスタムトランスポート - **`github_readme` は 0.5 で 100% 落ちる** = D6 の `all_dropped` が実際に発火する形 - **本文抽出に失敗するページが実在する**(explainx は Readability.js が 286 字しか返さず、 `MIN_CHARS` 未満で対象外)。fetch が本文を取れないことは村でも起きる **rev1 の限界の読みが 1 つ誤っていた** — rev1 は「自前の粗い変換なのでナビぶんの削減が過大」と 書いたが、readability を通しても削減率は下がらなかった(arxiv 54% → 51%)。 **readability が落とすのはナビ・フッター、Jev が落とすのは本文の中の依頼に無関係な節で、 2 つは別のものを落としている。** **基準の対測(D5 / 未決 2 の答え。下限を迂回して採点)**: | | 依頼文で残す | 理由で残す | 判定の一致 | 依頼文で残り理由で落ちる | |---|---|---|---|---| | rag_memory | 81% | 81% | 50/51 | 1 段落 | | rag_b69 | 36% | 32% | 121/126 | 5 段落 | | rag_contract | 14% | 19% | 41/44 | 1 段落 | | rag_design | 61% | 51% | 71/86 | **12 段落** | **ずれるときは常に「依頼文で残り理由で落ちる」側**(沈み 19 対 浮き 5)。沈むのは依頼の副論点で、 `rag_design` の「現代的な反例」に当たる章が理由基準では 0.11〜0.12 に落ちる。**そのうえ理由の実測字数は 13 / 13 / 19 / 20 で、4 本中 3 本が D5 の下限 20 字未満**。質と下限の二重で退けられる。 **英語の基準**(質問文は日本語固定のまま。arxiv 300 段落): 判定の一致 **268/300**、 残す量の差 -10%。**日本語固定の質問文で英語の基準は動く**(D5 の未測定が埋まった)。 **決定性と同席依存(中間帯 0.15〜0.28。前回は低得点帯しか測れていなかった)**: - **同じ束ねでの振れは 0.04〜0.05**(rev1 の 0.11 より小さい)。閾値 0.2 をまたぐのは 2/8 - **束ね方を変えたときの差は 0.17〜0.29**、またぐのは 5/8・6/8。**中間帯は文脈で揺れる** - ただし **D4 は束ね方を決定的に固定している**(先頭から 16 段落・12,000 字)ので、同じ本文・ 同じ依頼なら同じ束ねができる。実運用で効くのは前者(0.04)だけ **費用と遅延**: 236 呼び出し / 入力 934,446 トークン / **$0.039** / 遅延 中央 0.56〜0.78 秒・ 最大 1.63 秒。**429 は 1 回も出ていない**(4 並列・236 呼び出し)。120K 字の 1 件は 19 呼び出し。 **要否判定(既定の閾値を確定した測定。2026-09-22)**: 帯 0.15〜0.28 から 46 段落を抜き、 各サンプルの依頼文と突き合わせて判定した。**要った 13 / 要らなかった 33。** | 閾値 | 誤って落とす | 誤って残す | 合計 | |---|---|---|---| | 0.15 | 0 | 33 | 33 | | 0.18 | 2 | 15 | 17 | | **0.20(確定)** | **3** | **12** | 15 | | 0.22 | 6 | 6 | **12** | | 0.25 | 11 | 3 | 14 | | 0.30 | 13 | 0 | 13 | **合計が最小なのは 0.22 だが 0.2 を採った**(利用者裁定)— **2 種類の誤りのコストが等価ではない**。 誤って残すのはトークンを払うだけで害が費用に閉じるが、誤って落とすと答えの材料が消え、 `omitted` で戻れるのは**モデルが「足りない」と気づいたときだけ**。しかも 0.20 → 0.22 で 落とす誤りが 3 → 6 に倍増し、0.25 では 11(要った 13 の 85%)になる。 **誤って落とした 3 件はすべて依頼の副論点だった** — CLA への署名(提出手順の一部)/ 「Auto Mode」の節(置き場所の名前)/ RT タイミング種別(タイムコードの周辺)。 実測の「誤って落とすのは依頼文の 2 つ目以降の論点」と一致する。**閾値では解けない** (解くなら依頼文を 1 論点ずつに割る側の話で、本 Spec の範囲外)。 **この判定の限界**: 判定を下したのも LLM なので **Jev との独立性は完全ではない** (方法は違う — 全文脈を持って読む 対 1 段落ずつ確率を返す)/ **帯の外は判定していない** (`< 0.15` の 118 段落・`>= 0.28` の 96 段落)ので、上の表は**帯の中での相対比較**であり 「要る段落の 23% を落とす」という数字ではない。 ### 返り値の形(**rev3 の骨格を決めた測定**) 村のログ(`fuseforks.log` 2026-08-09〜09-22)で、対象の呼び出しは **1,236 件・うち 4,000 字以上が 416 件(34%)= 5,246,474 字**。その 416 件を、**実際にツールを叩いて**返り値の形で分類した: | 分類 | 字数 | 割合 | 実測した例 | |---|---|---|---| | **A. JSON でない** | 1,283K | **24.5%** | `MCP_DOCKER__fetch`(markdown)/ `rag` | | **B. JSON・包装型**(中身が本文) | 291K | 5.5% | `manuale__read` — `content` に Markdown が丸ごと入る | | **C. JSON・配列型** | 1,581K | 30.1% | `outcasts__recent_posts` / `memoria__recall_memory` / `manuale__search` | | D. 同サーバーの他ツール(高確度で JSON) | 949K | 18.1% | `alphaxiv__*` / `manuale__navigate` | | E. 不明(この端末から叩けない) | 1,142K | 21.8% | `elyth__*` / `browsermcp__*` | **rev2 までの D4(JSON は丸ごと素通し)は、対象の 3/4 を捨てていた。** 通しの効き目: ```text ツール出力 12.25M 字 → 実効コストの 11〜22% A のみ 1.28M × 47% = 602K = ツール出力の 4.9% → 実効の 0.5〜1.1% A+B+D 2.52M × 47% = 1.18M = ツール出力の 9.7% → 実効の 1.1〜2.1% A+B+C+D 4.10M × 47% = 1.93M = ツール出力の 15.7% → 実効の 1.7〜3.5% ``` **利用者裁定(2026-09-22): B は本 Spec で扱う(D4)。C は別 Spec(Spec 60)へ切る。** ### rev1 起票時の実測(2026-09-21。材料が自前の粗い変換・割り方が段落の途中を切る形) ~~記事 15K 字 22% / 論文の HTML 110K 字 54% / ブログ 9K 字 43%(閾値 0.2)~~ — **割り方が D4 ではなかった**(`buf[:1500]` で段落の途中を切っていた)ので、数字は P0 の側が正。 残るのは 2 点だけ: ナビ・フッター・参考文献・結果の表は 0.01〜0.12、依頼の核は 0.85〜0.94 で **両端は分かれる** / **誤って落とすのは依頼文の 2 つ目以降の論点**(0.24〜0.46 / 0.12〜0.17)。 **未測定のまま残るもの**(P0 を閉じた時点で 2 つ): **返り値の形の E の 21.8%** (`elyth__*` / `browsermcp__*` はこの端末から叩けない。包装型なら本 Spec が拾い、配列型なら Spec 60 の効き目が伸びる)/ **要否判定の帯の外**(`< 0.15` の 118 段落・`>= 0.28` の 96 段落。 両端は分かれるという実測に寄りかかっている)。 ## Design ### D1. 対象は「ツールが自分で名乗る」— 既定は対象外 `AgentTool` に既定実装つきのメソッドを 1 本足す(`wants_reason` と同じ形): ```rust /// 返した本文を、関連度で段落単位に落としてよいか(Spec 59)。既定は偽。 fn prunable(&self) -> bool { false } ``` 真を返すのは **`McpTool` だけ**(rev4)。**名前の表(除外リスト)で持たない** — 新しい同梱ツールは 何もしなければ対象外になる。`file` の出力は `sd` の材料で逐語が要り、`run` の固定枠 12,000 字は RepeatGuard の完全一致のためにある。どちらも「足し忘れたら圧縮される」形にしない。 ~~`RagTool` も真を返す。~~ **rev4(2026-09-22)で `rag` を対象から外した。** P4 の実機で **12,051 字が 2 段落にしか割れず**、2 回とも `all_dropped` で全文へ倒れた(`tool prune:` の `paragraphs=2 dropped=0 calls=1`)。`tools/rag.rs` の `render_tree` は見出しを改行で並べるだけで **空行を 1 つも出さない**ので、**D4 の 2(空行で割る)が構造的に効かない**。割り方に見出しを足せば 効きうるが、それは P0 の測定(Web ページ 6 本 + 実ファイル 4 本を空行で割って取った数字)を 丸ごとやり直す話になる。`RagTool::prunable` が偽であることは単体が留める(親切心で戻さない)。 **合成側(`plan` / `ask_*` / `transfer_to_*` / `room_log`、および本 Spec が足す `omitted`)は `AgentTool` を実装していない。** `prunable()` というメソッド自体を持たないので、構造上ここへ来ない (rev1 は D7 に「`omitted` の出力は `prunable` ではない」と書いていたが、合成ツールにとっては 同語反復なので削除した。査読 1-C1 / 2-1.2)。 ### D2. 掛ける場所は `CallRunner` の 1 箇所。RepeatGuard は圧縮前を数える **実装の並びは動かさない。** `turn.rs:2153-2202` の実際の順序は次で、`observe` は `record` / `emit` / `tool:` 行の**後ろ**にある。rev1 の図はこれを前に書いていたので、 図どおりに並べ替えると Spec 57 の記録点が動く(査読の追加点 A)。 ```text result(ask / plan / room_log / execute_tool の合流点。turn.rs:2153) → raw = Ok(text) なら text / Err(err) なら「ツールの実行に失敗しました: …」 → body = 下の条件をすべて満たすなら prune(raw)、満たさなければ raw → tool_calls.record(&call.args, &body) … Spec 57 は「モデルへ返した本文」= 圧縮後 → emit(ToolInvoked) → `tool:` 行 … body_chars は圧縮後 → repeat_guard.observe(&call.name, &call.args, &raw) … **引数を raw にして守る** → CallOutcome::Executed(body) ``` **守るのは順序ではなく引数。** RepeatGuard を圧縮前で数えるのは、Jev が決定的でないため(実測)。 圧縮後で数えると、同じ失敗を繰り返していても本文が回ごとに 1 段落ずれて、完全一致の数えに乗らない。 **この選択は B 案(戻る道を作らない)を構造で排除する**(査読 1-3-2)。モデルが圧縮後の本文を見て 「足りない」と思い同じ引数で呼び直すと、`raw` は同じなので 3 回目に RepeatGuard が止める。 そのときモデルに残る手は `omitted` だけになる。**意図した設計であり、バグではない。** 圧縮する条件(すべて真のとき): - 設定が ON かつキーが在る(`Shared` の scorer が `Some`) - `result` が `Ok`(`Err` の整形文は圧縮しない = `raw` と `body` が同じ文字列になる) - 呼び出しが registry の枝で、引けたツールの `prunable()` が真 - `raw` が `MIN_CHARS`(4,000)以上 - **基準が 20 字以上**(D5。満たさなければ `outcome=no_basis`) - 本文が JSON でない、**または JSON の包装型**(D4b)。配列型ほかは `structured` で素通し。 判定は**ローカル**で、ここで落ちたら外部へ 1 バイトも出ない **注: `execute_tool` は「そのツールはありません」でも `Ok` を返す**(`turn.rs:2865`)。 `is_runnable` が先に落とすので到達せず、到達しても 4,000 字未満で条件から落ちる。 解決の 1 実装: いま `registry_reason`(`turn.rs:2812`)と `execute_tool`(同 2839)が同じ 「個別 MCP → 共有 registry」の逆引きを 2 箇所に書いている。3 箇所目を作らず、 `resolve_registry_tool(shared, agent_id, name)` へ寄せて 3 つ(理由・実行・`prunable`)が 同じ 1 本を引く。**順が食い違うと、実行したツールと `prunable` を引いたツールが別物になる** (`registry_reason` の doc が同じ罠を既に書いている)。 ### D3. コアは trait、Jev は 1 実装 ```rust #[async_trait] pub trait ParagraphScorer: Send + Sync { /// 段落ごとの 0.0〜1.0。返らなかった段落は `None`(= 残す)。 async fn score(&self, request: &str, paragraphs: &[&str]) -> CoreResult; } ``` - `crates/fuseforks-core/src/prune.rs` — 純機構(段落へ割る / 採点結果から残す段落を決める / 省略の印を入れて組み立てる)。HTTP を知らない。単体テストは偽の `ParagraphScorer` で書く - `crates/fuseforks-core/src/jev.rs` — Kataribe の `crates/llm_client/src/jev.rs` を写す (`encode` / `decode` は純関数・Cloudflare の二重包装を剥がす・`json()` 直ではなく text→parse・ 402 は再試行しない)。v1 は Noul だけ。`impl ParagraphScorer for JevClient` - `Shared` は `RwLock>>` を持つ。GUI 層が設定とキーから組んで差し込む。 `None` なら D2 の条件で落ちて何も起きない ### D4. 段落の割り方と、採点しないもの **順序を固定する**(査読 1-2。順が無いと「短い段落を柵へ寄せて柵が壊れる」「寄せ続けて上限を 超える」が実装ごとに割れる): 1. **``` の柵を閉じた単位として確保する**(柵の中の空行では割らない) 2. **空行で割る** 3. **6,000 字を超える段落を「採点しない・残す」へ確定**(Jev の state は約 32k トークン)。 以後のマージの対象にも相手にもしない 4. 残りのうち **120 字未満を次の段落へ寄せる**。次が無ければ(末尾)**前へ寄せる**。 ただし**寄せる先が 3 で確定したものか、寄せた結果 6,000 字を超えるなら寄せない**(単独で残す) 5. ここで並んだものが**段落**。番号は **0 始まりの index**(マージ後)。以後すべて — 本文の印・ `omitted` の `from` / `to`・ログの `paragraphs=` — この番号で数える 6. 採点の束は先頭から詰めて **16 段落・12,000 字**まで(超える手前で切る)。呼び出しは **4 並列** **段落の途中では切らない。** また: - **JSON として解釈できる本文は、包装型だけ圧縮する**(下の D4b)。それ以外は素通し (`outcome=structured`)— 段落で割ると構文が壊れ、モデルは壊れた JSON を読まされる。 **判定はローカル**なので、ここで落ちた本文は外部へ 1 バイトも出ない(D10) - 質問文と criteria は**実測した文面をコードの定数として固定**し、テストで逐語に留める (Kataribe `failures.md` #104 — 一文の増減で 0.36 動く。変えたら測り直す)。この文面を読むのは Jev で サーヴァントのモデルではないので、Spec 35 の二言語化の対象外(1 言語のまま) ### D4b. JSON の包装型だけは中身を圧縮する(rev3。利用者裁定 2026-09-22) **実測で、対象の 30% が JSON の配列型・5.5% が包装型だった**(上の「返り値の形」)。 包装型は **JSON に包まれているだけで中身は本文**なので、素通しにすると効き目を捨てる (`manuale__read` は `content` に Markdown が丸ごと入る)。 **包装型の判定**(すべて真のとき。1 つでも外れたら `structured` で素通し): - トップレベルが JSON のオブジェクト - **その直下の文字列値のうち最長のものが、本文全体の 60% 以上**を占める - **その文字列が 4,000 字(`MIN_CHARS`)以上** - **その文字列の JSON 表現(`serde_json::to_string`)が、元のテキストに ちょうど 1 回だけ現れる** **入れ子の中までは探さない。配列は見ない。** 形が揃わなければ丸ごと諦める — AionUi の `toon` が 「全要素が同じ鍵数のオブジェクトで入れ子がゼロでないと `None` を返す」形を採ったのと同じ規律で、 **部分的に解釈して構文を壊す経路を作らない**。 **差し替えは元テキストの上で行い、再シリアライズしない**: ```text raw(JSON のテキスト) → parse して最長の文字列値 s を特定 → s の JSON 表現を元テキストで探す(1 回だけ現れることが条件) → s の中身に D4 の 5 段を当てて圧縮 → s' → 元テキストのその範囲だけを s' の JSON 表現へ差し替える ``` **再シリアライズすると整形・キーの順序・他の値の表現が変わりうる**ので採らない。 この方法だけが「**圧縮した文字列以外は 1 バイトも変わらない**」を保証する(Goal 2 の逐語)。 省略の印(D7)は圧縮した文字列の中に入るので、JSON の文字列としてエスケープされて出る。 **配列型(実測 30.1%)は本 Spec では扱わない** — 要素単位で落とす形になり、印の書き方も 戻り道(`omitted` の指し方)も別の設計になる。 **[Spec 60](60_json-array-pruning.md) として切る**(利用者裁定 2026-09-22)。 ### D5. 関連度の基準 基準 = **そのターンの受信本文(`incoming.content`)の先頭 2,000 字**。MCP のツールには理由欄が無い (`ReasonState::Unsupported`)ので、呼び出しごとの意図は取れない。 **`@@` の参照(Spec 58)の写しは含めない。** `incoming.content` は封筒も写しも含まない生の本文で (Spec 58 P4 で確定)、写しは 1 通あたり最大 30,000 字になりうる。含めると先頭 2,000 字が写しで 埋まって依頼文そのものが落ちる。**参照を渡したターンでは、基準は依頼文だけになる**(査読の追加点 C)。 **基準が `trim()` 後 20 字未満なら圧縮しない**(`outcome=no_basis`。裁定 5)。「了解」「続けて」の ようなターンでツールが 4,000 字返す場面は実在し、その基準で採点すると**ほぼ全段落が無関係に振れる**。 `all_dropped` の網(D6)は「1 段落だけ残る」を受けないので、**基準そのものが無いときは機構を止める**。 `no_basis` を `all_dropped` と別の `outcome` にするのは、畳むと後から区別できないため(#72 の規律)。 `rag` は理由欄を持つ(Spec 27)が、**基準は `rag` でも依頼文のまま**(rev3 で確定。未決 2 は消えた)。 P0 で 4 本を対で測った結果が退けた — **ずれるときは常に「依頼文で残り理由で落ちる」側** (沈み 19 段落 対 浮き 5 段落)で、沈むのは依頼の副論点そのもの。**そのうえ理由の実測字数は 13 / 13 / 19 / 20 字で、4 本中 3 本が下の下限 20 字に届かない**。質と下限の二重で退けられる。 **帰結として D10 の送信物は 1 つに固定された**(常に依頼文の先頭 2,000 字)。 **英語の基準も P0 で撃った**(査読 2-2.7)— 質問文が日本語固定のまま英語の基準で採点し、 判定の一致 **268/300**・残す量の差 -10%。**動く。** 村の言語が `En` でも基準はそのまま渡す。 ### D6. 閾値 `0.1 / 0.2 / 0.3 / 0.5` の離散 4 値。**既定 0.2**(実測で副論点の段落が 0.24〜0.46 に居たため、 0.3 以上は既定にしない)。集合に無い値は既定へ落とす(`theme` / 表示倍率と同じ)。 **画面のラベルと値の対応(裁定 3。ログは数値・画面はラベル)**: | ラベル | 値 | |---|---| | 控えめ | 0.1 | | **標準(既定)** | **0.2** | | 強め | 0.3 | | 最大 | 0.5 | 説明 1 行を添える(強いほど、依頼の 2 つ目以降の論点に答える段落が先に落ちる)。 **全部落ちたら圧縮しない**(`outcome=all_dropped`)。**判定は「採点した段落が 1 つ以上あり、 そのすべてが閾値未満」のときだけ**(裁定)。6,000 字超の段落は採点対象ではないので `kept` 扱いで、 それだけが残る結果は `ok`。残す段落が 0 になる採点は、依頼と結果が噛み合っていないか採点の失敗で、 どちらでも全文を返すほうが害が小さい。 **採点対象が 1 つも無い**(全段落が 6,000 字超)ときは Jev を呼ばず、`outcome=ok` で `calls=0 dropped=0`。外部送信も起きない(D11 の註でこの読み方を固定する)。 #### D6b. 正味の削減が 25% に満たなければ圧縮しない(rev4。利用者裁定 2026-09-22) `MIN_REDUCTION = 0.25`(コード定数。設定に出さない)。**分母は元の本文、分子は印を入れた後の差**で、 **落とした字数ではない** — 印は 150 字前後あるので、少ししか落ちない本文では足すほうが多くなる。 **論拠は P4 の実機 5 件**(下の「P4 実機記録」が正): | ツール | 落とした割合 | 正味 | 門なら | |---|---|---|---| | `alphaxiv__get_paper_content` | 1.3% | −125 字(0.6%) | 適用しない | | `MCP_DOCKER__fetch` | 9.5% | −238 字(5.8%)。**次の周で `omitted` が 380 字を読み直した** | 適用しない | | `rag` ×2 | 0% | 0 | 元から適用しない | **5 件とも門に掛かり、正味 +17 字の赤字が 0 になっていた。** 形は上流([fast-jev-compaction](https://github.com/tamaratran/fast-jev-compaction))の `minReductionRatio` と同じで、既定も同じ 0.25。あちらは `(charsBefore - charsAfter) / charsBefore` で測っており、**印のぶんを差し引く点まで同じ**(Notes 8 に読みを書いた)。 **圧縮しなかった理由は畳まない。** rev3 までは `all_dropped` と「1 つも落ちなかった」が同じ `None` に 落ちていたが、門が 3 つ目の理由になったので `Applied` の 4 値へ割った(`Pruned` / `AllDropped` / `NothingDropped` / `BelowFloor { ratio }`)。**どれで見送ったかがログから読めないと、門が効いているかを 数えられない**(`failures.md` #72)。`outcome` は 7 値 → **9 値**。 ### D7. 落としたことを本文に書く / 戻る道 圧縮した本文の形(ja。en は Spec 35 の規律で対に書く): ```text 【関連度で段落を省略しています: 全 48 段落・9,388 字のうち 21 段落・3,926 字を省略。 省略した段落は `omitted` に id と段落番号を渡すと逐語で読めます。id=P2】 (残した段落 0) [… 段落 8〜14・7 段落・1,204 字を省略 …] (残した段落 15) ``` **生本文の置き場は `run_turn` のローカル**(査読 2-1.1。最重要): ```rust /// 圧縮した呼び出しの生本文(Spec 59)。**寿命はターン。** struct PrunedRaw { id: String, raw: String } ``` `CallRunner` は `pruned: &'a mut Vec` を借りる — **`repeat_guard` / `plan_wave` と 同じ形**。`CallRunner` は周回ごとのブロックで作り直されて Drop されるので(`turn.rs:1593-1624`)、 フィールドに `Vec` を持たせると、**圧縮は Round 1・`omitted` の呼び出しは Round 2** という 本来の使い方で必ず `not_found` になる。メモリだけで、ログにも `sessions.redb` にも書かない。 **`omitted` は合成ツール**(`room_log` と同じ枠): - **`is_runnable` に足す**(`turn.rs:2012` の `room_log` と同型。査読の追加点 B)。 足し忘れると呼び出しが素通りし、**モデルが呼んだのに何も起きず本文だけ返る** (エラーにならないので気づけない — 同じ罠が既にコメントで名指しされている) - 引数 `{ id, from, to }`。**`from` / `to` は D4 の 0 始まり index で inclusive**、 末尾は段落数でクランプ(査読 2-2.3) - **返すのは落とした段落だけ。** 範囲に保持した段落が含まれていたら飛ばし、落とした段落だけを 順に返す(範囲指定は「どこを読みたいか」で、保持済みを混ぜると**省略への戻り道ではなく 生ログの再取得**になる。査読 1-3-1) - 1 回 20,000 字まで(`room_log` と同じ) - **提示は「圧縮が ON で、かつその個体の提示集合に `prunable` なツールが 1 本以上あるとき」だけ** = 静的(チェックを切り替えた 1 回だけ入力キャッシュが書き直しになる)。判定は**提示集合から引く 1 実装**で、`RagTool::spec_for` の動的除外(宣言フォルダが空なら提示しない)もそこに畳まれる (査読 2-1.3。rev1 は未決 1 に「全個体の毎ターンに乗る」と書いており、D7 と食い違っていた) **結末ごとの本文**(`failures.md` #44 — 打ち切りは「何が起きたか」と「次に何をするか」を 両方書いて初めて完成する。査読 2-2.6): | `outcome` | 本文 | |---|---| | `ok` | 落とした段落を逐語で | | `not_dropped` | 「指定された範囲に省略された段落はありません。その範囲は本文に既に含まれています。」 | | `not_found` | 「id=P2 の本文はこのターンには在りません。省略の印に書かれている id を渡してください。」 | | `too_large` | 「指定された範囲は N 字あり、上限の 20,000 字を超えています。範囲を狭めてください(例: from=8, to=10)。」 | **採らなかった案 B**(戻る道を作らず印だけ書く): モデルの唯一の手が同じツールの呼び直しになり、 D2 の RepeatGuard(圧縮前を数える)に当たって止まる。**再現不能なループになるので採らない。** ### D8. 失敗と打ち切り — 全文を通す - **全体で 20 秒**まで。超えたら**済んだバッチの判定も捨てて全文**(`outcome=timeout`。裁定 6)。 **部分適用しない理由**: 落ちる段落の分布が呼び出しの速さで変わり、**同じ入力で結果が変わる経路が 2 つ目**になる(Jev の非決定性に加えて)。全文へ倒すほうが説明できる - **一部のバッチだけが失敗**(HTTP エラー・解釈できない応答)で他が時間内に済んだときは、 失敗したバッチの段落を残して続ける(`None` = 残す)。`outcome=ok`・`calls=` は成功数。 **全バッチが失敗したら `outcome=failed`** - HTTP の失敗・402・解釈できない応答は全文(`outcome=failed`)。再試行は 429 / 5xx に 1 回だけ - **待ちはターンの打ち切り(Spec 10)で切れる**(`select!` で `turn.token` を見る)。切れたら全文を 返して、ターンの側の打ち切り処理へ渡す(`outcome=cancelled`) Jev は検証器ではないので fail-open でよい(eigent の採点器の教訓とは向きが逆 — あちらは 「壊れたら合格」が害で、こちらは「壊れたら圧縮しない」= 今日の挙動へ戻るだけ)。 ### D9. 設定と置き場 | もの | 置き場 | 理由 | |---|---|---| | Account ID / 圧縮の ON・OFF / 閾値 | `{app_data_dir}/jev.json` | `mcp_server.json` / `pricing.json` と同じ第 3 の棚。**村を配ったとき、受け取った人の村が、知らない送信先へツール結果を送る状態を作らない** | | API トークン | OS の資格情報ストア(`SecretStore`。鍵 `jev_api_token`) | モデルの API キーと同じ扱い。値を返す IPC は作らない(「設定済み」だけ返す) | **`jev.json` が読めないとき**(査読 1-3-3): - **機構ごと OFF** — scorer を差し込まない。`tool prune:` は 1 行も出ない。 「読めないファイルを ON として扱う」= 知らない送信先へ送る状態を作らない側へ倒す - **書き込みも拒む**(`mcp_server.json` と同じ。既定値の書き戻しで Account ID と閾値を消さない) - システム設定のページに「設定ファイルが読めません」を出す。**黙って OFF にしない** (利用者の意図は失われるが、失われたことは画面に出る) 読めるが ON でキーが無いときも OFF(チェックは `disabled`)。 画面: **システム設定 > 外部連携 > Jev**(「MCP サーバー」の下)。Account ID / API トークン(書くだけ)/ 「接続を確かめる」(2 段落の採点を 1 回投げて、モデルの版と遅延を出す)/ **「ツール結果を Jev で 圧縮する」のチェック** / 閾値(ラベル。D6 の表)/ 送るものの説明(D10)。チェックはキーが揃うまで `disabled`。反映は押した時点(MCP サーバーのページと同じ)。 IPC は `get_jev_settings` / `set_jev_settings` / `set_jev_token` / `clear_jev_token` / `test_jev` の 5 本。 投影は持たない(読むのはこのページだけ)ので生の `ipc` でよい。 ### D10. 外へ送るもの(PRIVACY) **送信が起きるのは、実際に Jev へ採点を投げるときだけ**(査読 1-C2。rev1 は「4,000 字以上を返す たびに送られる」と書いており、**文面が嘘だった**)。次のどれかで落ちた呼び出しは、判定がすべて ローカルなので**外部へ 1 バイトも出ない**: - 圧縮が OFF / キーが無い / 対象外のツール(`prunable()` が偽) - `raw` が `MIN_CHARS`(4,000)未満 - 本文が JSON で、**包装型ではない**(配列型ほか。`outcome=structured`) - 基準が 20 字未満(`outcome=no_basis`) - 採点対象の段落が 0 件(全段落が 6,000 字超。`calls=0`) **実際に送るもの**は 2 つ: 1. **基準** — **そのターンの依頼文の先頭 2,000 字**(rev3 で 1 つに固定。`rag` も同じ) 2. **採点する段落** — ツールが返した本文のうち、6,000 字超の段落を除いたもの。 **JSON の包装型では、包装を除いた中身の文字列から割った段落**(キー名・他の値は送らない) `rag` の本文は利用者が宣言したフォルダの中身で、MCP の本文は接続先が返したもの(記憶・メール・ 社内文書を返す MCP を繋いでいればそれも含む)。送信先は Cloudflare(Workers AI。モデルの提供元は TypeSafe AI)。ページの説明文と PRIVACY 日英の「4. 外部へ送信される情報」にこの 2 つを名指しで書く。 既定 OFF・キーが無ければ送信経路ごと無い。 ### D11. 計器(件数だけ。本文は 1 字も出さない) ```text tool prune: agent=… name=… outcome=ok|structured|no_basis|all_dropped|timeout|failed|cancelled shape=text|json_wrapper raw_chars=… kept_chars=… paragraphs=… dropped=… calls=… jev_tokens=… ms=… threshold=0.2 tool omitted: agent=… id=P2 from=8 to=14 chars=1204 outcome=ok|not_dropped|not_found|too_large ``` - `tool prune:` は圧縮を**試みた**呼び出しでだけ出す(4,000 字未満・対象外・OFF では 1 行も増えない) - **`shape=` は D4b が効いたかを読む欄**(`outcome=ok` に畳むと、包装型で圧縮できた件数が 分からなくなる)。`structured` の行は `shape=` を持たない — 形が包装型でないことがその `outcome` そのものなので、2 つ目の欄で同じことを言わない - **`calls=0` は「採点対象が 1 つも無かった」**(全段落が 6,000 字超)。Jev を呼んでいないので 外部送信も無い。`outcome=ok dropped=0` と対で読む - `tool:` 行の `body_chars` は今までどおり「モデルへ返した本文」= 圧縮後 - **`tool omitted:` の回数が、落としすぎの実測になる**(多ければ閾値が強すぎる)。 `not_dropped` が多ければ、モデルが段落番号を取り違えている - **`MIN_CHARS: usize = 4000` はコード定数**(設定に出さない。未決 4 の裁定) Jev のトークンは `TurnSpend` にも予算にも載せない(単価が入力 $0.042/MTok で、実測 15 万トークンが $0.0064。村の天井は実効トークン建てで、桁が 2 つ下の別の課金を混ぜると数字の意味が割れる)。 `jev_tokens=` をログに出すので、払った量は `fuseforks.log` から読める(#103 の形にしない)。 ## Tasks ### P0 — 測ってから凍結する(2026-09-22。要否判定を除いて完了) - [x] **村が実際に受け取る形で測り直す** — Readability.js + markdownify で 6 本 + `ManualeRAG` の 実ファイル 4 本。閾値 0.2 で字数加重 47%(上の「P0」) - [x] **JSON で返る MCP の割合を数える** — 実際にツールを叩いて分類した結果が **D4b を生んだ** (A 24.5% / B 5.5% / C 30.1% / D 18.1% / E 21.8%) - [x] **落とした段落の要否を判定する**(2026-09-22。帯 0.15〜0.28 の 46 段落。要った 13 / 要らなかった 33)→ **既定 0.2 を確定**(下の表) - [x] **基準を対で測る**(依頼文 対 `rag` の理由)→ 依頼文のまま確定(未決 2 が消えた) - [x] **英語の基準で 1 本撃つ** → 判定の一致 268/300 で動く - [x] 中間帯の同席依存 → 束ね方で 0.17〜0.29 動くが、同じ束ねでの振れは 0.04〜0.05 - [x] 110K 字級の 1 件の所要(19 呼び出し)と、レート制限(236 呼び出しで **429 は 0 件**) - [x] `data_contract.yaml` に `tool_prune_contract`(2026-09-22 着地。`tool_call_detail_contract` の 隣・5,232 字・不変条件 14 本。凍結: 対象は `prunable` の自己申告 / 圧縮は返った瞬間の 1 回 / 逐語・段落境界 / **段落の割り方の順序と 0 始まり index** / **JSON は包装型だけ・差し替えは 元テキストの上で行い再シリアライズしない** / RepeatGuard は圧縮前で位置は動かさない / **生本文の寿命はターンで置き場は `run_turn`** / `omitted` は合成で `is_runnable` へ / 基準と下限 / 失敗は全文 / 置き場 2 つ / **送信は実際に投げるときだけ** / ログは件数だけ / 閾値の 4 値と対応表。**既定 0.2 だけは要否判定の測定待ちと明記した**) ### P1 — コア(2026-09-22 完了) - [x] `prune.rs`(割る・選ぶ・組み立てる。純関数)+ 単体。**D4 の 5 段の順序を単体で留める** (柵の直前の短い段落 / 末尾の短い段落 / 寄せると 6,000 字を超える組 / 12,000 字の束の境目) - [x] **D4b の包装型**(判定 4 条件 / 元テキストの上での差し替え / 諦める枝)+ 単体。 **「圧縮した文字列以外が 1 バイトも変わらない」を golden で留める**(キーの順序・整形・ 他の値・末尾の改行)。諦める枝は 4 条件それぞれで 1 本ずつ - [x] `jev.rs`(Kataribe から写す)+ 単体(`encode` / `decode` / 402 は一過性でない)+ live 1 本(`#[ignore]`) - [x] `AgentTool::prunable` / `resolve_registry_tool` の寄せ(2 箇所 → 1 本) - [x] **`run_turn` のローカルへ `Vec` を置き、`CallRunner` へ `&mut` で渡す** - [x] **`omitted`(合成)** — `is_runnable` への追加 / 提示条件(提示集合に `prunable` が 1 本以上)/ 4 つの `outcome` と本文 / 0 始まり inclusive - [x] 結合: 偽の採点器で、落ちる・全文へ落ちる 7 つの `outcome`・RepeatGuard が圧縮前を数える・ **`omitted` が次の周(Round 2)で逐語を返す**・OFF でバイト等価(golden)・打ち切りで待ちが切れる - [x] ミューテーション(予測を先に書く): RepeatGuard を圧縮後へ / `prunable` の既定を真へ / JSON の素通しを外す / 全部落ちたときの全文返しを外す / **生本文を `CallRunner` のフィールドへ戻す** (予測: 次の周の `omitted` が `not_found`)/ **`is_runnable` から `omitted` を抜く** (予測: 呼び出しが素通りして本文だけ返る)/ **包装型の差し替えを再シリアライズへ変える** (予測: 「1 バイトも変わらない」の golden だけが赤)/ **包装型の 60% の門を 0% へ** (予測: 配列型が包装型として通り、構文が壊れる本文を返すテストが赤) ### P2 — GUI(2026-09-22 完了) - [x] `jev_settings.rs`(GUI 層。`pricing_source.rs` と同じ形)+ IPC 5 本 + 起動時の差し込み - [x] システム設定のページ + 辞書 ja / en + 走査テスト(辞書の鍵 / チェックの `disabled` の条件 / **閾値のラベルと値の対応表**) - [x] **設定ファイルが読めないときの表示**(D9) - [x] 会話ペインのツール行(Spec 57)は変更なし — 開けば省略の印ごと見える ### P3 — 台帳(2026-09-22 完了) - [x] DETAIL 日英 / README 3 言語 / **PRIVACY 日英(D10。送信は実際に投げるときだけ、を名指しで)** / ランディングページは固有の機能を列挙していないので触らない ### P4 — 実機 - [x] 論文 1 本を MCP で取らせ、`tool prune: outcome=ok` と、次の周の `cache:` 行で `cached` が 前の周の `prompt` 付近に居ること(圧縮が前方一致を壊していない)— **4 例で成立** (記録 1 の 2 例 + 記録 3 の 2 例。どれも差は 3 トークン以内) - [x] ~~同じ依頼を ON / OFF で 1 回ずつ。`turn:` 行の `prompt` の差~~ **→ 対照は取らずに閉じた** (2026-09-22 利用者裁定)。**rev4 で問いが変わったため** — 門を入れた後に測るのは 「門を超える入力が実在するか」で、`ratio=` と字数がその差をそのまま持つ。OFF 側で 新たに測れるのは `omitted` の再取得だけで、それは記録 1 で既に観測している - [x] **`omitted` が次の周で呼ばれて逐語が返る**(`CallRunner` の寿命の検収。ここが rev1 の骨格の誤り) — 記録 1 で観測(落とした 380 字をモデルが読み直した) - [x] **配列型を返す MCP(`outcasts__recent_posts`)で `outcome=structured`**(19:32:46・20,012 字)/ **短い相槌のターンで `outcome=no_basis`**(20:28:22・6,554 字)。 **キーを消す / 回線を切るの 2 つは実機で踏まずに閉じた** — 結合テストが同じ経路を覆っている (`without_a_scorer_nothing_changes` = 採点器が居なければ 1 行も出ない / `a_failing_scorer_falls_back_to_the_full_text` = 失敗で全文が返りターンが続く)。 **鍵を消す操作は実機の設定を壊すので、確かめられることを確かめる側へ寄せた** - [x] ~~**包装型で `shape=json_wrapper` が出て、返った本文が JSON として読めるまま**~~ **→ 単体へ預けた**(Spec 55 P6 の `exists` と同じ扱い)。この村で包装型を返すのは `manuale__read` だが、**実機では `navigate` / `search` を挟むのでモデルが `read` に たどり着かず、4,000 字超の本文を引く形を狙って作れなかった**。覆っているのは `a_wrapper_json_is_pruned_in_place` と `everything_outside_the_compressed_string_is_byte_identical`(バイト golden) ## P1 実装記録(2026-09-22) Rust 1,070 全緑・clippy 警告ゼロ。新しい依存は 0(`futures` を足さずに済ませた)。 次に触る人が要る判断だけを置く。 - **締め切りと打ち切りは採点器の外**(`prune::with_deadline`)。`ParagraphScorer` は 打ち切りトークンを受け取らないので、`jev.rs` の中で待ちを切ると**偽の採点器を使う 結合テストには同じ網が掛からない**。`turn.rs` が `tokio::select!` で包む形にしたので、 実装が何であれ 20 秒で切れ、`biased` で打ち切りが締め切りより先に見られる (分類は `cancel > timeout`。`token_budget.precedence` と同じ向き) - **束ね方は `prune::batch` の 1 実装。** 採点器が受け取るのは `candidate_texts()` の 平らな列なので、`Prepared::batches()` も**この関数を通してから候補の index へ写す**。 2 箇所に書くと、片方がずれても型では落ちない(採点が 1 つ後ろの段落に付く形) - **並列は `JoinSet` ではなく `Pin>` の手詰め。** `JoinSet` は `'static` を要求するので段落を全部 clone することになり、しかも**外側の future を 落としても task が生き残る**(締め切りで捨てたはずの呼び出しが続く)。 借用のまま持つと、`with_deadline` が future ごと落とした瞬間に HTTP も止まる - **一部の束ねだけ失敗したらその段落を残して続ける。全部失敗したときだけ `Failed`。** 畳むと `apply` が「1 つも落ちなかった」を返し、ログの `outcome` が `all_dropped` に なって後から区別できない(#72 の規律) - **402 の非一過性は Spec 52 の `retry::classify` + `verdict` に委ねた。** Jev のために 2 つ目の再試行の表を書かない — 402 は `ClientError` → `Stop` に落ちる - **エラーに応答本文を載せない。** Cloudflare の本文は受信ヘッダーをエコーすることが あるので、`status` と分類だけを持つ(Spec 47 の 401 と同じ線。#71 の系譜) - **質問文と criteria は P0 で実測した文面の逐語**で、単体テストが 1 字ずつ留める。 一文の増減で 0.36 動く(Kataribe `failures.md` #104)ので、変えたら測り直す **ミューテーション 12 本、すべて予測どおり**(予測を先に書いてから回した): | 変異 | 予測 | 実測 | |---|---|---| | 生本文を `CallRunner` のフィールドへ戻す | 1 | **1**(次の周が `not_found`) | | `is_runnable` から `omitted` を抜く | 1 | **1**(同じ 1 本) | | `prunable` の既定を真へ | 1 | **1** | | RepeatGuard を圧縮後(`&body`)へ | **0** | **0**(既知の穴の確認) | | 束ねの合流を 1 段落ずらす | 2 | **2** | | 全滅時の `Failed` を消す | 1 | **1** | | 締め切りの腕を殺す | 1 | **1** | | `PARALLEL` 4 → 1 | **0** | **0**(速さの調整 = 負の対照) | | 402 を再送する | 2 | **2** | | 包装型の差し替えを再シリアライズへ | 1 | **1**(golden だけ) | | `WRAPPER_SHARE` 0.6 → 0 | 1 | **1**(少数派の枝) | | 打ち切りの配線(`turn.token`)を抜く | 1 | **1** — ただし**初版のテストは緑だった**(下) | **自分のテストの誤りを 1 つ踏んだ**(`failures.md` #136)。打ち切りの結合テストを 「`drain_until_quiet` の後に経過時間を測る」形で書いたので、**採点で固まっていても イベントが流れず静かになって緑**だった。配線を抜く変異で 0.61 秒のまま 6 本緑になり、 そこで初めて判定になっていないと分かった。`AgentTyping { active: false }` の**観測**へ 変えたら同じ変異で狙った 1 本が赤になり、走行が 5.06 秒へ伸びた。 **「静かになった」は「終わった」ではない。** **live テストは `jev.rs` に `#[ignore]` で 1 本**(実鍵・課金が要る)。合流と部分失敗は 実鍵では狙って起こせないので、`tests/jev_scorer.rs` のループバック・スタブが唯一の 検証経路になる(`attachment_fallback.rs` と同じ形)。 ```bash JEV_ACCOUNT_ID=... JEV_API_TOKEN=... \ cargo test -p fuseforks-core --lib jev::tests::live -- --ignored --nocapture ``` ## P2 実装記録(2026-09-22) workspace 1,103 全緑(GUI 層の単体 21 → 30)・vitest 685(+9)・clippy 警告ゼロ・vue-tsc 0・build 緑。 - **判定は `jev_settings.rs` の 2 本に閉じた** — `can_enable`(ON に**できる**か)と `is_active`(いま掛かっているか)。起動時の差し込み・設定変更後の差し込み・ チェックの `disabled`・画面の「いま有効です」の **4 箇所が同じ述語を読む**。 画面へは結果(`canEnable` / `active`)を返すので、**フロントは条件を組み直さない** — 組み直すと「画面では ON なのに掛かっていない」が作れる(Spec 20 の提示集合と 判定集合がずれた形) - **`enabled` と `active` を別の欄にした**(`mcp_server` の `enabled` / `listening` と同じ)。 設定上の ON と、実際に採点器が差し込まれているかは別の事実 - **設定 → `JevConfig` の写しは `scorer_for` の 1 実装**。`apply`(差し込む)と `test_jev`(接続を確かめる)が共有する — 2 つに割れると、確認だけ通って本番が 別の設定で動く形が作れる - **「接続を確かめる」は設定が OFF でも押せる。** 入れた鍵が通るかを ON にする前に 確かめたい。**押したときだけ外へ出る**のは `pricing` の凍結と同じ形で、 `jev_settings.rs` の単体が `state.rs` を走査して `.probe(` が無いことを留める - **`CoreError::JevProbe` を新設した**(`ConfigIo` へ畳まない)。「Account ID が無い」 「トークンが無い」「402(残高不足)」「通信の失敗」で次の手が全部違い、しかも `jev.json` は壊れていない — `PricingFetch` と同じ理由 - **閾値の表が Rust と TS で 2 枚になる**(画面の `