コンテンツにスキップ

挙動契約 (Behavior Contracts)

対象: CPersona 2.6.x。 このページの記述は現行リリースラインのソースと 照合済みです。ここに書かれた挙動は契約です。呼び出し側は依存してよく、 変更する場合は pre-release ladder とリリースノートを通ります (リリースライフサイクル 参照)。告知なく 変わることはありません。

翻訳について: 正本は英語版です。記述が食い違う場合は英語版が優先です (右上の言語切替から参照できます)。

このページは、ツール名だけからは取り違えやすい挙動を集めたものです。 いくつかは実運用のオペレーターが外側から CPersona を計測して発見しました。 挙動が意外に見えるものには、その場で理由を併記しています。


1. recall の返却順: 末尾が最良

recall は内部で候補を「良い順」に並べ、limit で切り、そのスライスを 反転します。応答はスコアの昇順で並んでおり、最後の要素が最強のマッチ です。

これは意図的な設計です。LLM は文脈の末尾に最も強く注意を向ける ("lost in the middle") ため、最強の記憶を注入点の手前側に置いています。

帰結:

  • 評価するとき: recall の応答に対して hit@k を測るなら、末尾から 数えてください。先頭から数えると結果が反転します。
  • recall_with_context は契約が異なります。 渡した会話履歴と想起した記憶を マージし、スコア順ではなく時系列順で返します。

ここでの時系列は、タイムスタンプの文字列ではなく、それが指す時刻です。 どの UTC オフセットで書かれていても、マージのどちら側が書いたものであれ、 他のすべてのスタンプに対して正しい位置に入ります。オフセットなしの表記は UTC として読まれ、同じ扱いになります。

タイムスタンプが無い、または解釈できないメッセージは時刻を指しません。 それらは時刻を持つすべてのメッセージより前に、マージされた順序のまま 置かれます。末尾は、本当に最新のものだけのための位置です。

2. confidence は各行の横に返され、順位は決めない

CPERSONA_CONFIDENCE_ENABLED (既定 false) は、返す各行に confidence の値を 加えます。2.6.0a7 からは、それだけです。既定の CPERSONA_CONFIDENCE_ORDERING=fusion では、この値は結果の順序も品質ゲートも決めません。confidence を on にした recall は、 off のときと同じ行を同じ順序で返し、confidence フィールドが増えるだけです。 理由は 一本化した事前分布 にあります。

CPERSONA_CONFIDENCE_ORDERING=legacy で、以前のリリースの挙動に戻ります。 legacy で confidence を on にすると:

  • 結果集合が confidence スコアで並べ直され、
  • 品質ゲートが融合スコアではなく confidence を見るようになります。

このとき融合モードはどの候補が結果集合に入るかは引き続き決めますが、 返ってくる順序はもう決めません。1,545 文書のコーパスに 394 クエリで実測しました。 この挙動で confidence を on にすると rsf と rrf は 394 クエリ全てで同一の行を 同一の順序で返し、off では一致率が 10% 未満でした。

confidence を on にしていた配備を更新すると、ゲートが比べるスコアが変わるので、 保存済みのゲートは起動時に較正し直されます (スコアリングの版が変わったため)。

ゲートのシグナル優先順位は rsf > cosine > rrf で、confidence が先頭に来るのは legacy のときだけです。スコアの付いた行では match_reason.signal が、その行で 実際にどの分岐が効いたかを報告します。事前分布の年齢の重みを設定しているときは、 match_reason.prior がその行の順位を決めた重みを報告します。

スコアの付かない行は、このキー自体を持ちません。cascade recall が埋める FTS / keyword 行と、注入プロフィール行がそれです。 legacy で confidence を on にしたときは、プロフィール行も他の行と同様にスコアが付き match_reason を持ちます。match_reason は「全行にあるフィールド」ではなく 「あるかないか」として扱ってください。

なお confidence はマッチ強度ではありません。コサイン類似度・時間減衰・ resolved 状態・想起回数をブレンドした別の量です。完全一致の行が言い換えの行 よりこの尺度で低く出ることは、正当に起こり得ます。

3. エピソード境界ペナルティ

2.6.0a7 から既定で無効です。 使う場合は CPERSONA_EPISODE_PENALTY_ENABLED=true で有効にします。セッションの終わりごとに エピソードを保存するエージェントでは、履歴のほぼ全体が境界より前になるため、 ペナルティを有効にすると過去の記憶がすべて半減し、答えを持つ記録より無関係な行が 上に来ることがあります。この節の以下は、有効にした時の挙動です。

エピソードが存在するとき、最新のエピソード境界より古い記憶には減衰係数が 掛かります:

factor = max(exp(-RATE × hours_before_boundary), FLOOR)
つまみ 環境変数 既定値
有効化 CPERSONA_EPISODE_PENALTY_ENABLED false
レート CPERSONA_EPISODE_DECAY_RATE 0.01
下限 CPERSONA_EPISODE_DECAY_FLOOR 0.5
  • 境界は最新エピソードの created_at で、クエリと同じ分離軸 (agent / project / channel) にスコープされます。無関係なバケットの エピソードが、あなたの境界を動かすことはありません。
  • 境界以降の記憶 (現在のセッション) は影響を受けません (係数 1.0)。
  • 既定値では係数は約 69 時間 (ln 2 / 0.01) で下限に達します。つまり 約 3 日より古いものは一律に半減します。この機構は現在のセッションへの 緩い優先であって、細かい新しさランキングではありません。直近数日の 内部での順序付けは、この分解能の外です。緩やかなカーブが欲しければ RATE=0.002 でランプを約 2 週間に伸ばせます。

一括インポートの落とし穴 (ペナルティ有効時)。 境界は単に「最も新しい エピソード行」です。過去の会話を archive_episode で後から流し込むと、 インポート時刻が境界になり、既存の記憶が全てペナルティ領域に落ちます。 エピソードを後から流し込まないか、インポート中はペナルティを無効のまま (CPERSONA_EPISODE_PENALTY_ENABLED=false、既定値) にしてください。

4. ベクトル走査ウィンドウ (CPERSONA_MAX_MEMORIES)

CPERSONA_MAX_MEMORIES (既定 10000) は保存上限ではありません。 ベクトル検索の走査ウィンドウです。ベクトル検索は直近 N 行だけを対象に します (記憶とエピソードはそれぞれ別にウィンドウ適用されます)。ウィンドウより 古い行はベクトル検索からは見えません。ただしウィンドウ制限のない FTS 経路と キーワード経路からは到達できます。

このウィンドウは同時に「新しさの事前分布」でもあります。 最も新しい行だけを 残すことで、最近の記憶それぞれに対して、コーパス全体ではなく N 行という候補集合を 与えています。これは精度のコストではなく、精度そのものです。

237,654 件の保存済み文書での実測では、ウィンドウを 10,000 から 200,000 に広げると、 答えがウィンドウより古い側にある場合は NDCG@10 が 4.93 向上し、ウィンドウ内に ある場合は 20.19 低下しました (結果の切り詰めは一切発生していません)。

この低下は順位の押し出しです。ベクトル検索は融合層に上位 limit 件だけを渡します。 10,000 件中 3 位だった最近の答えが 200,000 件中 30 位になると、それはリストの下位に 落ちるのではなくリストから外れ、票そのものが消えます。したがってこの値を 上げるのは単なる緩和ではありません。 事前分布を取り除くことで到達距離を 伸ばしています。

CPERSONA_VECTOR_REACH (既定 0) はこの 2 つを分離します。効果を持たせるには CPERSONA_MAX_MEMORIES より大きく設定する必要があります。同値以下では何も 変わらず、追加の処理も走りません。

大きくすると、ウィンドウと reach の間にある行が第 2 のリストとしてランク付けされ、 もう 1 本のランク済みリストとして融合層に渡されます。しきい値も打ち切りも同点処理も、 1 本目と同じです。ウィンドウの幅は変わらないので、 いま結果に入っている行はいまと同じ票を保ち、古い行は追加されうるだけです。

適用範囲には 2 つの制限があります。融合モード限定 (CPERSONA_RECALL_MODE=rrf または rsf。cascade はリストを融合せず段を連結 するので、この設定を無視します) と、ローカルのベクトル検索限定 (CPERSONA_VECTOR_SEARCH_MODE=remote ではサービス側が自身のウィンドウで ランク付けします) です。

rsf では遠方リストは第 4 のチャネルとして融合されます。合計がアクティブな チャネル数で割られる関係上、cosine スケールの min_score に対して融合スコア 全体が下がります。この設定の事前登録された測定は rrf に対するもので、rsf については何の主張もしていません。

CPERSONA_VECTOR_FAR_LIMIT (既定 0) は、その第 2 のリストのうち何行を融合層に 渡すかを制限します。既定では上限は応答の limit で、これは reach だけを設定した ときにできるリストそのものです。0 より大きくすると、limit とその値の小さい方 まで、同じリストの先頭から切り詰められます。決めるのは遠方の何行が投票できるか だけで、行のスコア計算には一切関与しません。

ウィンドウを上げることは、同じ動作で到達範囲を伸ばし、新しさの優遇を消します。 実測では、古い答えで 5 点を得る代わりに、最近の答えで NDCG@10 を 20 点失いました (REACH_AND_RECENCY_PLAN.md)。これは値段の付いたつまみであって、大きなコーパスに 対するサポートされた答えではありません。既定値は far の票に値段が付くまで 動きません。

I/O のコスト目安です。768 次元 float32 の埋め込みは 1 行あたり約 3 KB なので、 10,000 行のウィンドウでは最悪ケースで 1 回の recall あたり約 60 MB を読みます (記憶 + エピソード)。reach を有効にした場合のコストも同じ形で効きます。1 回の recall が現在より CPERSONA_VECTOR_REACH − CPERSONA_MAX_MEMORIES 行だけ多く 埋め込みを読みます。

連続配置ベクトルインデックスが構築済みなら、それはインデックスの高速経路です。 無ければチャンク分割されたテーブル走査になります。後者のレイテンシは、237,654 行の コーパスに対する reach 200,000 の実測で既定の約 2 倍でした (いずれの場合も キーワード経路が下限として効きます)。recall がメモリに保持する埋め込みは、 どちらの数値に対しても、1 チャンクとマップするインデックスファイル以上には増えません。 増えるのは 1 行ごとの id とスコアです。(2.6.0b2 より前は、build 以降に書かれた行が あるか選択に隙間があるためにコピーが要るインデックスの窓を、丸ごと保持していました。)

アーカイブや間引きの定期処理は不要です。長期的なモデルは物理削除をせず、古い行はウィンドウと減衰で沈ませるというものです。

5. 重複排除の意味論: upsert ではなく skip

store は 2 通りの重複排除を行いますが、両者のスコープは異なります:

  • msg_id 重複排除。 既存の msg_id を持つ store はスキップされ ます (result: "skipped"、既存行の id をエコー)。この判定は agent と project には及びますが、channel には及びません。同じ msg_id を別の channel に 書いても、最初の channel の行に対してスキップされます。
  • 内容重複排除。 同一の内容文字列も同様にスキップされ、agent / project / channel にスコープされます。ユニーク索引が裏打ちしますが、その範囲は厳密な バケット (agent_id, project_id, channel, content) に限られる一方、先に走る 判定はグローバルプールも見ます。したがって異なる project バケットへ並行に 書き込む 2 者は、どちらも着地しえます。

決定的な帰結: upsert は存在しません。 同じ msg_id で変更された内容を 再保存しても、保存済みの行は更新されません。スキップされます。保存済みの 記憶を変更するには update_memory (自動で再埋め込み) か、delete_memory + store を使ってください。

裏を返せば、依存してよい保証でもあります。変更のない内容の再投入は構造上 無害なので、コーパスを素朴に丸ごと再投入しても安全です。この意味論の上で 文書索引を回す方法は コーパス索引パターン を参照してください。

ハンドラまで到達した store は result を持ちます。stored (行を書いた)、 skipped (重複ヒット、または永続化が一時停止中。異常ではありません)、rejected (拒否、reason 付き) のいずれかです。

その上位に 1 層あり、そこは汎用の形で答えます。ACL を設定している場合、 クライアントに許可されていない呼び出しは {ok: false, error: "permission_denied", tool, client_id} を返し、result を 持ちません。まず ok is false で分岐し、その後に result を見てください。

6. autocut は類似度スケールのシグナルでしか発火しない

autocut (スコア差が最大の箇所で切り落とす仕組み) は、スコアの差が関連性の 切れ目を表しているという前提に立ちます。これが成り立つのは類似度スケールの シグナルだけです:

  • 発火するのは、confidence による並べ替えの下 (confidence on かつ CPERSONA_CONFIDENCE_ORDERING=legacy)、または全行がシグナルを持つ 均質な生コサインのリストです。
  • 意図的に不活性なのは、rsf と rrf の順序付け下です。ランク融合の スコアは構造上双曲的に減衰し、その差は関連性の切れ目ではなく retriever の 重なりを表します。融合順の結果では、混入の抑制は融合側の品質ゲートが 担います。

したがって既定構成 (confidence off、rrf または rsf) では、 CPERSONA_AUTOCUT_MIN_RESULTS をいじっても recall 件数は変わりません。 融合モードでゲートを動かすつまみは set_recall_precision です。 recall のチューニング を参照してください。

7. プロフィール行はスコアを持たない

update_profile の行は注入行として recall 応答に付加され、スコアリングには 参加しません。行は最大 1 件です。profiles は (agent_id, user_id) で一意で あり、どの書き込み経路も user_id を '' に束縛するため、2 回目の update_profile は蓄積されず 1 件目を置き換えます。

  • 行が 50 件未満のプールでは、confidence の on / off にかかわらずどのスコア 分岐よりも前に落とされます。ここでのプールは、その recall の隔離スコープに おける memories + episodes の合計件数です。したがって小さなコーパスや狭く スコープされたコーパスでは、recall の他の設定がどうであれプロフィール行は 返りません。30 件のプールで、どの保存行も答えないクエリ (= limit は何も 切り落とさない) を confidence on で実測: 応答は空、同じ実行内の 50 件の 対照ではプロフィールが返りました。
  • この閾値を超えたうえで confidence off (既定) の場合、プロフィール行は スコアを持たず最後尾に並び、スコア付きの結果だけで limit が埋まっていると 切り落とされます。実コーパスに rsf / limit=10 で実測: 生き残ったプロフィール行は 0 件でした。
  • この閾値を超えたうえで CPERSONA_CONFIDENCE_ORDERING=legacy で confidence on の場合、プロフィール行は高い confidence スコアを受け取り、安定して上位に現れます。 既定の fusion (2.6.0a7 以降) では、confidence on でもここは confidence off と 同じで、プロフィール行は最後に並びます。

プロフィールを「常に必ず注入されるチャネル」として扱わないでください。常に必ず存在してほしい事実に 対する正しい機構は、確率的な recall ではなく決定的注入 (あなたの CLAUDE.md やシステムプロンプト) です。 recall に頼らないという選択 を参照してください。

8. gate_fallback の応答は低信頼

recall の応答が gate_fallback: true を伴う場合 (伴わないときは欠落)、 全ての候補が品質ゲートを下回ったため、空の結果の代わりにゲート未満の 字句マッチを返したという意味です。これらの行は低信頼として扱ってください。 識別子やハッシュの検索で、完全一致がクエリ文と意味的に遠い場合に典型的です。

この救済経路は confidence による並べ替えの下にしか存在しません。 返される行に 印を付けるのは、CPERSONA_CONFIDENCE_ENABLED が有効で、かつ CPERSONA_CONFIDENCE_ORDERING=legacy のときだけ走る backfill です。 したがって既定の構成でも、confidence on で既定の fusion のときでも、 gate_fallback は決して現れません。全候補がゲートを下回った recall は、単に空を返します。

9. lock_memory は保護する、押し上げはしない

lock_memory は行を削除と編集から保護します。ランキングには影響しません。 ロックした記憶でも recall に負けることはあります。要件が「決して失われては ならない」ならロックしてください。要件が「常に文脈に載っていてほしい」なら、 決定的注入を使ってください (プロフィールに関する注意は §7 を参照)。

10. 応答の形: 成功と失敗の見分け方

v2.5.2 以降、規則は統一されています: ok is false で分岐し、error を 伴う応答は ok の有無にかかわらず失敗として扱ってください。

同リリースで 3 点が変わりました。いずれも、それまでの形では失敗が成功として 読めてしまったためです。

  • store は結果を result で報告します — stored / skipped / rejected — 常に true の ok ではなくなりました。以前は拒否された書き込みが 成功と同じ形に見えていました (§5 参照)。
  • check_health は status という単一の判定を返します。 従来の healthy ブール値はありません。
  • ハンドラが返すツールレベルの失敗は、すべて ok: false を伴います。 以前は多くが error だけを返し、分岐できる ok がありませんでした。説明は 従来どおり error で運ばれます。ただし store だけは reason に入ります。

次の 2 つの形は、この規則の外側にあります。以前からそうでした。

  • 最外層の MCP ディスパッチは、未知のツール名や、ハンドラから漏れた例外に 対して、ok を持たない素の error で応答します。この層は他の Cloto サーバーと 共有するライブラリから vendoring されているため、揃えるにはローカルの修正では なく上流の変更が要ります。
  • 成功した読み取り (get_contents / list_memories / list_episodes / get_profile) も、ok を持たないペイロードを返します。

どちらも上の規則で覆われます。規則を「ok を確認する」ではなく「error を伴う 応答は失敗として扱う」と述べているのは、そのためです。

11. 宣言された連想は reconstruct と traverse だけが読む

エージェントが宣言したもの — entity、別名、関係 — は recall の応答を 1 つも変えず、 何も宣言されていなければ reconstruct の応答も変えません: どちらもテーブルの無いストアと byte-identical です。reconstruct がそれを読む場所は次のとおりです:

  • 別名が足すのは票であって、通過ではありません。 問いが宣言された entity を名指すと、 その他の名前がキーワード検索にだけ追加されます; 埋め込み検索・採点・品質ゲートは書かれたとおりの 問いを見続けます。別名を通じてだけ見つかった記録はその問いに対して判定され、別名が無い時と まったく同じくゲートの下に留まります。別名がするのは、もう一方の検索が既に票を入れた記録を 押し上げることです。
  • 2 つの記録の間の関係はそれらを束ねます — 両方が候補である時に 1 つの item へ。述語が role 語なら 主語をその role として示します。entity の共有は決して束ねません。
  • entity 間の関係は根拠を足し、item は足しません。 item の候補から到達した entity を言及する 記録は、その item の内側に why: "relation:<predicate>" と hops を伴って、ホップ数の少ない順に 加わります。item にはならず、返る item やその順序を変えず、1 つの応答に 2 度現れもしません。

対象は宣言されたものちょうどです: サーバは何も抽出せず何も推論せず、誤った宣言は取り消される まで残ります。連想記憶の設計 を、実装が確定させた規則は §9 を 参照してください。

12. スコアは 1 つの応答の中の順序であり、答えがあるかどうかは示さない

match_reason.score はその行について品質ゲートが比べた値で、どの値かは match_reason.signal が示します (§2)。どういう数かは信号によって違います:

  • rsf (CPERSONA_RECALL_MODE=rsf での信号): 検索経路ごとのスコアをその問いの中で min-max 正規化し、足し合わせ、何かを返した経路の数で割ります。経路が 2 本のとき、片方の 経路が 1 位にし、もう片方が見つけなかった行は、一致がどれほど弱くてもちょうど 0.5 になります。 両方が 1 位にした行は 1.0 です。経路が 1 本なら最上位の行は 1.0、3 本なら片方だけの 1 位は 0.333 です。 この数は問いに対する相対値です。
  • cosine (rrf で、コサインを持つ行): 問いと行の生のコサイン類似度です。これは絶対値です。
  • rrf (rrf で、コサインを持たない行): 順位の逆数の和です。一致の近さではなく順位で決まります。

相対値のスコアが並べるのは 1 つの応答の中の行です。問いをまたいで比べることはできず、閾値を 置いても、本当の答えと「何も無い中での最良」を分けられません。rsf では、記憶が持ちえない話題の 問いでも、既知の当たりと同じ 0.5 が返ります。

match_reason.cosine (ある場合) は、信号が何であってもそのコサインで、行に付く唯一の絶対値です。 ただしエピソード境界ペナルティが有効な場合 (§3、2.6.0a7 から既定で無効) は、最新のエピソードより 古い記憶について、コサインと rsf のスコアが最小で半分まで縮められます。 答えのある問いと無い問いをどこまで分けられるかは測っていません。閾値を置く前に、ご自分の問いで 分布を確かめてください。confidence (§2) も合成値であり、一致の強さではありません。

「何も無い」を示すもの (ただし実際に recall が空になるのは、主にどの経路も何も返さなかった時です):

  • 既定の構成では、すべての候補が品質ゲートに届かない recall は行を返しません (§8)。
  • 候補の無い reconstruct は shortfall_reason を持ちます: no_relevant_evidence、または ゲートに届かない行だけが見つかった場合は below_quality_threshold (§8)。

rsf では、ゲートが相対値の融合スコアを絶対値の閾値と比べるため、弱い候補ばかりでも通り、 rsf の recall が空になることはほとんどありません。これは未解決の欠陥です (bug-247)。 直し方の 1 つ (ゲートのスコアを固定の尺度にする) は試して取り下げました: サーバーが起動時に 行う較正の下で、実際の agent の記憶のパックの、答えのある 250 問のうち 52 問と、答えの無い 50 問のうち 11 問を空にしました。複数の経路で見つかった行のスコアが足し合わさって 1.0 を超え、 較正の閾値が、1 本の経路で見つかった行にはまず届かない位置に置かれたためです (結果)。 ほかの直し方は残っています。

「その記録はありません」と言う必要がある呼び出し側は、上位行のスコアではなく、その記録が 必ず含むはずの語での完全一致の検索 (キーワード検索、または記憶の元になったソースファイルへの grep) で裏付けてください。