diff --git a/README.md b/README.md index e467e9c..2038323 100644 --- a/README.md +++ b/README.md @@ -248,7 +248,7 @@ neuron-graph-rag-mcp \ } ``` -利用順序は `search` で得た `trace_id` と候補を保持し、実際の判断過程に合わせて `selected` → `validated` → `used` を `record_source_use` へ送ります。既定 policy では新規 `used` が credited edge の独立 evidence を記録し、設定 quorum 到達時に bounded reinforcement を発火します。confirmed candidate 有効時は `used` まで weight を変更せず、後から `record_outcome` へ送った独立 `confirmed` だけが初回 multiplier `1.0`、後続 geometric decay で保存済み relation path を強化します。`corrected`、`rolled_back`、`superseded` はどちらの policy でも weight を変更しません。 +利用順序は `search` で得た `trace_id` と候補を保持し、実際の判断過程に合わせて `selected` → `validated` → `used` を `record_source_use` へ送ります。既定 policy では新規 `used` が credited edge の独立 evidence を記録し、設定 quorum 到達時に bounded reinforcement を発火します。confirmed-only candidate 有効時は `used` まで weight を変更せず、後から `record_outcome` へ送った独立 `confirmed` だけが初回 multiplier `1.0`、後続 geometric decay で保存済み relation path を強化します。`corrected`、`rolled_back`、`superseded` は既定 policy と confirmed-only policy のどちらでも weight を変更しません。 三つの tool の schema、model-facing description、trace retention、failure code の正本は [docs/optional-mcp-interface.md](docs/optional-mcp-interface.md) です。実装範囲は local stdio に限り、HTTP、認証、認可、remote deployment は含みません。 @@ -296,6 +296,8 @@ Evidence-gated local feedback reinforcementは、credited edgeごとに異なる Confirmed-outcome feedback reinforcementは、同じ candidate class の `confirmed_outcome_reinforcement=True` と明示 `confirmation_decay_ratio` で有効にします。`used` は履歴だけを保存し、relation trace 上で used となった node の一意な credited path を、独立 `confirmed` outcome ごとに減衰強化します。count、multiplier、actual delta、credited path は core / MCP receipt で同じ形に写され、SQLite restart 後も継続します。mechanics は default 採用や q3/s1 との優位性を主張しません。詳細は[Confirmed-outcome feedback reinforcement](docs/confirmed-outcome-feedback-reinforcement.md)を参照してください。 +Soft-start feedback reinforcementは、`soft_start_feedback_reinforcement=True`、`soft_start_feedback_ratio`、`confirmation_decay_ratio` を明示する別の default-off candidate です。credited relation edge の最初の新規 `used` は通常 bounded update の一部だけを provisional に適用し、最初の独立 `confirmed` が残りを補完します。後続 confirmation は既存 geometric decay に従い、same-source sibling normalization は `used` ではなく各 confirmation の actual delta だけへ適用します。confirmed-only candidate、hard quorum と同時には有効化できず、既存 q1/s0、q3/s1、confirmed-only、凍結評価 artifact と default fingerprint は変更しません。詳細は[Confirmed-outcome feedback reinforcement](docs/confirmed-outcome-feedback-reinforcement.md#soft-start-successor-candidate)を参照してください。 + 後続の feedback policy comparison に使う public source は、[feedback-policy-comparison-v1](corpora/feedback-policy-comparison-v1/README.md) に corpus-only で固定しています。development / holdout 各二 cluster の明示 link topology、source-only manifest、raw SHA-256、LF 改行規則、provenance、既存 fixture との identity contamination audit だけを含み、評価の選択入力と観測物は含めません。この source merge commit を後続の別 issue が唯一の入力として扱います。 この corpus を使う `used + q3 + sibling normalization 1.0` と `confirmed + decay 0.5 + sibling normalization 1.0` の比較は、[Feedback policy comparison evaluation](docs/feedback-policy-comparison-evaluation.md) に result-free protocol、one-time development、conditional holdout、exclusive observed result、再計算可能な hard gate を固定しています。controlled result は default adoption や production quality を意味しません。 diff --git a/docs/Decision-Structure.md b/docs/Decision-Structure.md index 7b2a249..69526b5 100644 --- a/docs/Decision-Structure.md +++ b/docs/Decision-Structure.md @@ -13,10 +13,12 @@ | [observation-lifecycle-test-exception](https://github.com/Liplus-Project/neuron-graph-rag/wiki/observation-lifecycle-test-exception) | active | #31 の observed-state lifecycle test exception は hash、exclusive-write、no-recompute assertion に限定する。 | | [ceiling-aware-feedback-adaptation-gate](https://github.com/Liplus-Project/neuron-graph-rag/wiki/ceiling-aware-feedback-adaptation-gate) | active | 新規 feedback-adaptation experiment は baseline relation MRR が 1.0 未満なら strict improvement、1.0 なら全 safety gate を満たす non-regression を要求する。ceiling pass は default や一般化を意味しない。 | | [evidence-gated-local-feedback-reinforcement](https://github.com/Liplus-Project/neuron-graph-rag/wiki/evidence-gated-local-feedback-reinforcement) | active | relation edge ごとの独立 success trace が固定 quorum に達するまで serving weight を変えず、到達後は既存 bounded reinforcement と same-source sibling normalization を一回ずつ適用する。candidate は default-preserving であり、採用値と一般化を主張しない。 | +| [confirmed-outcome-feedback-reinforcement](https://github.com/Liplus-Project/neuron-graph-rag/wiki/confirmed-outcome-feedback-reinforcement) | superseded | confirmed-only candidate の mechanics と証拠は保持するが、利用直後の小さな適応を残す [soft-start-feedback-reinforcement](https://github.com/Liplus-Project/neuron-graph-rag/wiki/soft-start-feedback-reinforcement) が後続比較の現在候補としてこの判断を supersede する。 | | [frozen-output-round-trip-integrity](https://github.com/Liplus-Project/neuron-graph-rag/wiki/frozen-output-round-trip-integrity) | active | result-free evaluation は canonical gate ID array を唯一の順序正本とし、freeze 前に登録外 placeholder と temporary output で実 writer から実 verifier への非アルファベット順 round-trip を証明する。observed result は exclusive creation し、array の完全性、順序、重複なし、全 gate pass を検証する。 | | [longitudinal-feedback-adaptation](https://github.com/Liplus-Project/neuron-graph-rag/wiki/longitudinal-feedback-adaptation) | active | longitudinal feedback-adaptation は、repository-native controlled corpus v3 の相互に独立した cluster と、その source 文書に明記した 0、1、3、10 credit ceiling を用いる。corpus phase は query、gold、schedule、runner、gate、manifest、result、既定値を定義・変更しない。 | | [single-corpus-real-feedback-validation](https://github.com/Liplus-Project/neuron-graph-rag/wiki/single-corpus-real-feedback-validation) | superseded | [repository-native-controlled-corpus](https://github.com/Liplus-Project/neuron-graph-rag/wiki/repository-native-controlled-corpus) がこの node を supersede する。以後の evaluation は、NGR repository に公開する固定 SHA の controlled corpus を source とし、D1 single-corpus experiment は capacity が増えるまで waiting とする。 | | [repository-native-controlled-corpus](https://github.com/Liplus-Project/neuron-graph-rag/wiki/repository-native-controlled-corpus) | active | repository-native controlled corpus v2 は、固定 SHA の公開 documentation と本文中の明示的な同一 directory 相対 link だけから、node、doc path、source URL、credited edge identity が相互に分離した development / holdout の各 3-edge path を導出する。v1 は provenance として保持する。これは controlled benchmark であり、外部 corpus への一般化、評価 query、gold、result、既定値変更を含まない。 | +| [soft-start-feedback-reinforcement](https://github.com/Liplus-Project/neuron-graph-rag/wiki/soft-start-feedback-reinforcement) | active | 最初の credited `used` に通常 bounded update の小さな provisional fraction を適用し、最初の独立 `confirmed` が remainder、後続 confirmation が geometric decay を適用する。sibling normalization は confirmation の actual delta だけを使い、default と凍結 artifact は変更しない。 | | [github-rag-mcp-replacement-compatibility](https://github.com/Liplus-Project/neuron-graph-rag/wiki/github-rag-mcp-replacement-compatibility) | active | public GitHub repository一つのread-only snapshotをNGR local indexへ接続する。github-rag-mcp `search` の保存済み raw capture と source URL、根拠を比較する。共有 source identity を確認しても最小 doc 検索 path の候補に限り、production github-rag-mcp、MCP authentication / transport、remote deployment、default変更は含まない。 | ## Entry format diff --git a/docs/confirmed-outcome-feedback-reinforcement.md b/docs/confirmed-outcome-feedback-reinforcement.md index 1636b65..660d7cc 100644 --- a/docs/confirmed-outcome-feedback-reinforcement.md +++ b/docs/confirmed-outcome-feedback-reinforcement.md @@ -66,13 +66,49 @@ neuron-graph-rag-mcp \ candidate 有効時、MCP `search` は relation trace を返し、`tools/list` の source-use / outcome description も confirmed-triggered policy を明示する。無効時は既存 hybrid search、used-evidence reinforcement、audit-only delayed outcome description を保つ。 +## Soft-start successor candidate + +soft-start は confirmed-only と排他的な別の default-off candidate である。次の三値を同時に明示した場合だけ有効になる。 + +```python +EngineConfig( + soft_start_feedback_reinforcement=True, + soft_start_feedback_ratio=0.25, + confirmation_decay_ratio=0.5, +) +``` + +`soft_start_feedback_ratio` と `confirmation_decay_ratio` はどちらも有限の `0 < value < 1` とする。soft-start は `confirmed_outcome_reinforcement=True` または `relation_feedback_evidence_quorum != 1` と組み合わせられず、矛盾する設定は database を開く前に拒否する。 + +credited relation edge で最初に発生した新規 `used` は、通常の bounded increment の `soft_start_feedback_ratio` 倍だけを provisional に適用する。edge ごとの schedule は initial weight、base increment、soft-start ratio、confirmation decay、geometric maximum を SQLite に保存する。同じ edge の後続 `used` は独立 trace の policy marker と audit row を保存するが、provisional update を重複適用しない。`used` 時点では sibling normalization を一切行わない。 + +最初の独立 `confirmed` は count `1`、表示 multiplier `1 - soft_start_feedback_ratio` とし、edge を `min(maximum_edge_weight, geometric maximum, initial weight + base increment)` まで増やす。この target との差だけを actual delta とするため、provisional と最初の confirmation の合計は通常 bounded update 一回を超えず、途中で cap または別の増加があっても weight を減らさない。後続 confirmation `n` は multiplier `confirmation_decay_ratio^(n-1)` を使う。same-source sibling normalization は各 confirmation の actual delta だけに適用する。 + +source-use state、policy marker、provisional edge update、soft-start audit、idempotency receipt は一つの transaction で保存する。confirmed outcome 側も outcome、count、edge/sibling update、receipt を一つの transaction で保存する。receipt 保存を含む途中失敗は全変更を rollback する。core `SourceUseReceipt.feedback` と MCP output は provisional edge を同じ形で返し、`OutcomeReceipt` と MCP output は remainder または後続 decay の count、multiplier、actual delta を同じ形で返す。 + +local stdio server では次のように起動する。 + +```bash +neuron-graph-rag-mcp \ + --database /absolute/path/to/knowledge.db \ + --soft-start-feedback-reinforcement \ + --soft-start-feedback-ratio 0.25 \ + --confirmation-decay-ratio 0.5 +``` + +inactive な soft-start field は effective-config provenance へ追加しない。これにより既存 default、q3/s1、confirmed-only capture の canonical bytes と fingerprint を維持する。active な soft-start process だけが二つの soft-start field を provenance に含める。 + ## Adoption boundary この実装は mechanics、atomicity、receipt parity、default compatibility を固定する。decay ratio の採用値、q3/s1 に対する優位性、production default 変更は主張しない。比較には、既存 #76 / #77 artifact を変更、再実行、再集計しない fresh result-free evaluation を別 Issue で固定する必要がある。 +soft-start も同じ adoption boundary に従う。既存 #89 / PR #99 とその凍結 artifact は変更せず、soft-start、q3/s1、confirmed-only の比較は fresh successor evaluation として別に固定する。mechanics の実装だけで現在の local serving database または project default を切り替えない。 + ## Related - [Evidence-gated local feedback reinforcement](evidence-gated-local-feedback-reinforcement.md) - [Optional MCP feedback interface](optional-mcp-interface.md) - [Decision Structure](Decision-Structure.md) - [Issue #81](https://github.com/Liplus-Project/neuron-graph-rag/issues/81) +- [Issue #100](https://github.com/Liplus-Project/neuron-graph-rag/issues/100) +- [Soft-start feedback reinforcement decision](https://github.com/Liplus-Project/neuron-graph-rag/wiki/soft-start-feedback-reinforcement) diff --git a/docs/optional-mcp-interface.md b/docs/optional-mcp-interface.md index 497e9aa..35b68f4 100644 --- a/docs/optional-mcp-interface.md +++ b/docs/optional-mcp-interface.md @@ -40,6 +40,18 @@ neuron-graph-rag-mcp \ ratio は `0 < r < 1` とし、flag と ratio の片方だけを指定した起動は database を開く前に拒否する。candidate 有効時は MCP `search` が relation trace を返し、`used` は provenance のみ、独立 `confirmed` が初回 multiplier `1.0` と後続 `r^(n-1)` で credited path を強化する。詳細は [Confirmed-outcome feedback reinforcement](confirmed-outcome-feedback-reinforcement.md) を正本とする。 +soft-start candidate は次の三値を同時に明示した process だけで有効になる。 + +```bash +neuron-graph-rag-mcp \ + --database /absolute/path/to/knowledge.db \ + --soft-start-feedback-reinforcement \ + --soft-start-feedback-ratio 0.25 \ + --confirmation-decay-ratio 0.5 +``` + +soft-start ratio と confirmation decay は有限の `0 < value < 1` とし、confirmed-only flag、hard evidence quorum と同時には有効化できない。MCP `search` は relation trace と active soft-start field を含む effective-config provenance を返す。最初の新規 `used` は通常 bounded update の provisional fraction を core と同じ receipt 形で返し、最初の独立 `confirmed` は残り、後続 confirmation は geometric decay を返す。`used` は sibling を変更せず、confirmation の actual delta だけが sibling normalization の対象になる。詳細は [Confirmed-outcome feedback reinforcement の soft-start 節](confirmed-outcome-feedback-reinforcement.md#soft-start-successor-candidate) を正本とする。 + ## 2. Protocol envelope tool 名は `search`、`record_source_use`、`record_outcome` とする。すべての input と成功 output は JSON Schema で宣言し、未知 field を受け付けない。 @@ -124,11 +136,11 @@ source-use は一つの順序付き状態として扱う。 | `retrieved` | `search` の結果に候補として返った | NGR | なし | | `selected` | consuming AI が詳細確認する source として選んだ | client | なし | | `validated` | exact source を確認し、現在の判断材料として利用可能と判定した | client | なし | -| `used` | 最終回答、実装判断、レビュー判断などの根拠として実際に利用した | client | 既定 policy は新規遷移時に独立 evidence を記録して quorum 到達後に強化。confirmed candidate は履歴のみ | +| `used` | 最終回答、実装判断、レビュー判断などの根拠として実際に利用した | client | 既定 policy は新規遷移時に独立 evidence を記録して quorum 到達後に強化。confirmed-only candidate は履歴のみ。soft-start candidate は最初の credited edge に provisional update を一回だけ適用 | `retrieved -> selected -> validated -> used` の順序を守る。既存状態と同じ stage の再送は idempotent no-op とする。後退、段階の飛び越し、`retrieved` の client 申告は拒否する。同じ `record_source_use` call 内では、同一 node の連続する複数段階を順に送ってよい。 -`used` は「良さそう」「読んだ」という impression ではない。final artifact の根拠として使用した時点でのみ記録する。既定 policy では新しい `used` 遷移だけが credited edge の独立 evidence を記録でき、既定 quorum `1` では従来どおりその event が即時 reinforcement を発火する。quorum `2` 以上では到達前の serving weight を変更しない。confirmed candidate では `retrieved` から `used` まで evidence、weight、`reinforced_count` を変更せず、後続 `confirmed` だけを正の trigger とする。 +`used` は「良さそう」「読んだ」という impression ではない。final artifact の根拠として使用した時点でのみ記録する。既定 policy では新しい `used` 遷移だけが credited edge の独立 evidence を記録でき、既定 quorum `1` では従来どおりその event が即時 reinforcement を発火する。quorum `2` 以上では到達前の serving weight を変更しない。confirmed-only candidate では `retrieved` から `used` まで evidence、weight、`reinforced_count` を変更せず、後続 `confirmed` だけを正の trigger とする。soft-start candidate では最初の credited `used` が provisional update を一回だけ発火し、同じ edge の後続 `used` は provenance と confirmation eligibility だけを保存する。 ## 5. Tool: `search` @@ -218,7 +230,7 @@ In this deployment, trace handles expire ; feedback after expi `trace_expires_at` は自動 expiry がなければ `null`、retention があれば Unix timestamp seconds とする。 -`search` はさらに `effective_config_provenance` を必ず返す。これは実際に trace を生成した `EngineConfig` を、ranking と graph propagation に影響する `effective_config.retrieval` と、feedback mutation だけに影響する `effective_config.feedback` へ分離し、実際に選ばれた `search_surface`(`combined` / `relation`)を併記した機械可読 object である。各 config 区分と全体には、UTF-8・key sort・2-space indent・末尾 LF の canonical JSON bytes に対する `sha256:` fingerprint をそれぞれ `retrieval_config_fingerprint`、`feedback_config_fingerprint`、`full_config_fingerprint` として付ける。process default も明示値も、解決後の effective value を省略せず返す。`created_at` とこの provenance により、result-free feedback shadow v2 は同じ snapshot、query、limit、capture 時刻、retrieval config、search surface を exact replayできる。provenance の追加は serving retrieval/default、feedback policy、transport、deployment を変更しない。 +`search` はさらに `effective_config_provenance` を必ず返す。これは実際に trace を生成した `EngineConfig` を、ranking と graph propagation に影響する `effective_config.retrieval` と、feedback mutation だけに影響する `effective_config.feedback` へ分離し、実際に選ばれた `search_surface`(`combined` / `relation`)を併記した機械可読 object である。各 config 区分と全体には、UTF-8・key sort・2-space indent・末尾 LF の canonical JSON bytes に対する `sha256:` fingerprint をそれぞれ `retrieval_config_fingerprint`、`feedback_config_fingerprint`、`full_config_fingerprint` として付ける。既存 field は process default も明示値も解決後の effective value を返す。後続追加した soft-start field は active process だけで返し、inactive 時は frozen default、q3/s1、confirmed-only capture の canonical bytes と fingerprint を維持する。`created_at` とこの provenance により、result-free feedback shadow v2 は同じ snapshot、query、limit、capture 時刻、retrieval config、search surface を exact replayできる。provenance の追加は serving retrieval/default、feedback policy、transport、deployment を変更しない。 ### 5.5 Core mapping @@ -354,7 +366,7 @@ core domain API は取得済みでない node を stage 更新前に拒否し、 ### 7.1 Meaning -source を利用した判断や artifact に後から判明した結果を、即時 source-use とは別軸で記録する。既定 policy では評価用の履歴であり、edge weight を変更しない。明示 confirmed candidate では `confirmed` だけが保存済み relation credited path の diminishing reinforcement を発火できる。 +source を利用した判断や artifact に後から判明した結果を、即時 source-use とは別軸で記録する。既定 policy では評価用の履歴であり、edge weight を変更しない。明示 confirmed-only candidate では `confirmed` だけが保存済み relation credited path の diminishing reinforcement を発火できる。soft-start candidate では最初の `confirmed` が通常 update の残り、後続 `confirmed` が geometric decay を発火できる。 ### 7.2 Normative model-facing description @@ -373,7 +385,7 @@ Record a delayed outcome for sources that were already marked used, such as conf | `rolled_back` | 判断または artifact が撤回、revert、rollback された | | `superseded` | 誤りと断定せず、新しい前提または判断に置き換えられた | -`corrected` と `rolled_back` を即時の負の reinforcement に変換しない。query、index、source selection、source 自体、実装のどこに原因があるかを一件の outcome だけで判別できないためである。`confirmed` も `used` の reinforcement を重複加算しない。 +`corrected` と `rolled_back` を即時の負の reinforcement に変換しない。query、index、source selection、source 自体、実装のどこに原因があるかを一件の outcome だけで判別できないためである。既定 policy の `confirmed` も `used` の reinforcement を重複加算しない。soft-start の `confirmed` は provisional と合算して通常 update 一回を超えない remainder だけを最初に加算する。 ### 7.4 Input @@ -418,11 +430,11 @@ Record a delayed outcome for sources that were already marked used, such as conf } ``` -既定 policy の `reinforcement_applied` は常に `false` とする。confirmed candidate では新しい独立 edge confirmation を保存した時だけ `true` とし、`confirmations` に count、multiplier、actual delta、old/new weight、`credited_paths` に保存済み relation steps、`normalized_sibling_edges` に局所変更を返す。duplicate trace と idempotency replay は count と weight を重複変更しない。`corrected`、`rolled_back`、`superseded` は candidate 有効時も `false` のままである。 +既定 policy の `reinforcement_applied` は常に `false` とする。confirmed-only と soft-start candidate では新しい独立 edge confirmation を保存した時だけ `true` とし、`confirmations` に count、multiplier、actual delta、old/new weight、`credited_paths` に保存済み relation steps、`normalized_sibling_edges` に局所変更を返す。duplicate trace と idempotency replay は count と weight を重複変更しない。`corrected`、`rolled_back`、`superseded` は candidate 有効時も `false` のままである。 ### 7.6 Core mapping -transport-neutral な `FeedbackLedger.record_outcome` は既定 policy では outcome ledger にだけ保存する。confirmed candidate では `record_success` を再利用せず、outcome、confirmation count、edge/sibling update、receipt を candidate 専用の一つの storage transaction へ渡す。 +transport-neutral な `FeedbackLedger.record_outcome` は既定 policy では outcome ledger にだけ保存する。confirmed-only と soft-start candidate では `record_success` を再利用せず、outcome、confirmation count、edge/sibling update、receipt を candidate 専用の一つの storage transaction へ渡す。 ## 8. Failure contract @@ -499,8 +511,11 @@ MCP であることだけを理由に別 repository へ分離しない。次の - retry と duplicate stage が reinforcement を重複させない - 同一 trace、idempotency replay、duplicate stage が evidence count を重複させず、quorum 前は serving weight を変更しない - `corrected`、`rolled_back` を含む delayed outcome が weight を変更しない -- confirmed candidate では `used` まで weight が不変で、独立 `confirmed` が count `1` / multiplier `1.0` から固定 ratio で減衰し、core / MCP receipt が一致する -- confirmed candidate の duplicate trace、retry、lexical、zero-hop、uncredited path、別 source、途中失敗が count と weight を変更しない +- confirmed-only candidate では `used` まで weight が不変で、独立 `confirmed` が count `1` / multiplier `1.0` から固定 ratio で減衰し、core / MCP receipt が一致する +- confirmed-only candidate の duplicate trace、retry、lexical、zero-hop、uncredited path、別 source、途中失敗が count と weight を変更しない +- soft-start candidate では最初の credited `used` だけが provisional update を適用し、最初の `confirmed` との合計が通常 bounded update 一回以下、後続 confirmation が固定 ratio で減衰する +- soft-start の `used` は sibling を変更せず、confirmation の actual delta だけが sibling normalization へ反映され、core / MCP receipt が一致する +- soft-start の duplicate trace、retry、negative outcome、lexical、zero-hop、uncredited path、別 source、途中失敗が schedule と weight を不正に変更しない - invalid trace、trace 外 node、enum、stage 順序、idempotency conflict を拒否する - `tools/list` の description だけから feedback 順序、reinforcement 条件、delayed outcome 非変更規則を判断できる - persistent core では `trace_expires_at` が `null`、retention deployment では具体的な description と timestamp が一致する diff --git a/docs/requirements.md b/docs/requirements.md index a4efe6e..5f2291c 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -99,6 +99,7 @@ 80. sibling relation feedback normalization は、明示的に有効化された candidate config でのみ、relation trace の credited edge を強化し、その edge と同じ source から出る未 credit sibling だけを局所的に正規化できる。lexical trace、zero-hop、未関係 source、credited sibling は変更しない。candidate は synthetic isolation test と result-free development / holdout 相当の relation、direct、lexical、negative-control gate を通過するまで default にしない。 81. sibling normalization controlled evaluation は、評価対象 source commit / hash、相互に identity-disjoint な development / holdout cluster、明示 edge、baseline `0.0` / treatment `1.0`、query、used node、credited path、mutation scope、rollback、係数 / 時刻 schedule、hard gate、exclusive output を観測前に固定する。実 `NeuronGraphRAG` の `search_channels` relation trace ID を `record_success` に渡し、headroom strict improvement、ceiling・direct・lexical・directional-negative non-regression、path・mutation・atomicity・determinism の全 development gate 通過時だけ holdout を一度開く。観測後は protocol artifact と docs を変更せず、既定値と external D1 claim を変更しない。 82. frozen evaluation の historical source hash は、manifest path の初回追加 commit または manifest が明示する lowercase full 40-hex source / baseline / prior commit の exact blob bytes に対して検証する。後続の committed manifest rewrite、mutable ref / revision expression、未知 commit、非 ancestor commit、manifest bytes差、欠落 path、hash 不一致を fail closed にし、同名 path の current working tree を過去の evidence として扱わない。既存 protocol が明記する raw-first LF / CRLF whole-file alternate だけを維持し、本文差、mixed newline、bare CR、その他の byte 差を拒否する。 +83. soft-start feedback reinforcement は明示 opt-in の relation-only candidate とし、最初の新規 `used` で通常 bounded increment の固定 ratio 分だけを credited path へ適用する。最初の独立 `confirmed` は同じ schedule の残量を一回分の通常 increment まで補い、後続 confirmation は固定 decay ratio で加算する。used 時は sibling normalization を行わず、confirmation の actual delta だけを同一 source の uncredited sibling へ配分する。duplicate、lexical、zero-hop、別 source、uncredited edge、negative outcome は変更せず、candidate mechanics の合格だけで default や local serving policy を変更しない。 75. v3 implementation、prompt、manifest、query override、schema、集約、path audit、hash規則、gate、stop rule、testsをresult-free commitでpushした後、development stage / 4 case packet / 12 responses / resultを各一度だけ生成する。 76. development全12 gate通過時だけholdout stageを一度生成し、異なるfresh 12 judgesで同じgateを評価する。packet、response、resultの上書き、観測後の規則変更、実LLM品質値のCI再生成を拒否する。 @@ -134,3 +135,4 @@ - [Sibling relation feedback normalization](sibling-relation-feedback-normalization.md) が opt-in candidate の局所 sibling 正規化、trace isolation、default 変更前の検証境界を定義する。 - [Sibling normalization controlled evaluation](sibling-normalization-controlled-evaluation.md) が repository-native corpus、result-free hash freeze、実 relation trace feedback、mutation / rollback gate、conditional holdout を定義する。 - [Historical source verification](historical-source-verification.md) が frozen manifest path の初回追加 commit、明示 full source commit ID、exact blob、ancestor、path、newline portability、fail-closed 境界を定義する。 +- [Confirmed-outcome feedback reinforcement](confirmed-outcome-feedback-reinforcement.md) が confirmed-only と soft-start の明示 policy、永続 schedule、transaction、receipt、default-preserving boundary を定義する。 diff --git a/src/neuron_graph_rag/config_provenance.py b/src/neuron_graph_rag/config_provenance.py index c9cc864..deb73db 100644 --- a/src/neuron_graph_rag/config_provenance.py +++ b/src/neuron_graph_rag/config_provenance.py @@ -15,6 +15,8 @@ "relation_feedback_evidence_quorum", "confirmed_outcome_reinforcement", "confirmation_decay_ratio", + "soft_start_feedback_reinforcement", + "soft_start_feedback_ratio", ) SEARCH_SURFACES = ("combined", "relation") @@ -31,14 +33,23 @@ def effective_config(config: EngineConfig) -> dict[str, dict[str, Any]]: names = {field.name for field in fields(EngineConfig)} if set(raw) != names: raise RuntimeError("EngineConfig serialization is incomplete") + active_names = set(names) + if ( + raw["soft_start_feedback_reinforcement"] is False + and raw["soft_start_feedback_ratio"] is None + ): + active_names -= { + "soft_start_feedback_reinforcement", + "soft_start_feedback_ratio", + } return { "retrieval": { name: raw[name] - for name in sorted(names - set(FEEDBACK_CONFIG_FIELDS)) + for name in sorted(active_names - set(FEEDBACK_CONFIG_FIELDS)) }, "feedback": { name: raw[name] - for name in sorted(FEEDBACK_CONFIG_FIELDS) + for name in sorted(set(FEEDBACK_CONFIG_FIELDS) & active_names) }, } @@ -56,6 +67,7 @@ def effective_config_provenance(config: EngineConfig) -> dict[str, Any]: def effective_search_surface(config: EngineConfig) -> str: if ( config.confirmed_outcome_reinforcement + or config.soft_start_feedback_reinforcement or config.sibling_feedback_normalization > 0.0 ): return "relation" diff --git a/src/neuron_graph_rag/evidence_feedback.py b/src/neuron_graph_rag/evidence_feedback.py index fd98bbd..293d6c5 100644 --- a/src/neuron_graph_rag/evidence_feedback.py +++ b/src/neuron_graph_rag/evidence_feedback.py @@ -1,5 +1,6 @@ from __future__ import annotations +import math import uuid from collections.abc import Iterable from dataclasses import dataclass @@ -23,6 +24,8 @@ class EngineConfig(BaseEngineConfig): relation_feedback_evidence_quorum: int = 1 confirmed_outcome_reinforcement: bool = False confirmation_decay_ratio: float | None = None + soft_start_feedback_reinforcement: bool = False + soft_start_feedback_ratio: float | None = None def __post_init__(self) -> None: BaseEngineConfig.__post_init__(self) @@ -36,10 +39,24 @@ def __post_init__(self) -> None: ) if not isinstance(self.confirmed_outcome_reinforcement, bool): raise TypeError("confirmed_outcome_reinforcement must be a boolean") - if self.confirmed_outcome_reinforcement: + if not isinstance(self.soft_start_feedback_reinforcement, bool): + raise TypeError("soft_start_feedback_reinforcement must be a boolean") + if ( + self.confirmed_outcome_reinforcement + and self.soft_start_feedback_reinforcement + ): + raise ValueError( + "confirmed-only and soft-start feedback reinforcement are mutually exclusive" + ) + outcome_candidate = ( + self.confirmed_outcome_reinforcement + or self.soft_start_feedback_reinforcement + ) + if outcome_candidate: if ( isinstance(self.confirmation_decay_ratio, bool) or not isinstance(self.confirmation_decay_ratio, (int, float)) + or not math.isfinite(float(self.confirmation_decay_ratio)) or not 0.0 < float(self.confirmation_decay_ratio) < 1.0 ): raise ValueError( @@ -47,7 +64,27 @@ def __post_init__(self) -> None: ) elif self.confirmation_decay_ratio is not None: raise ValueError( - "confirmation_decay_ratio requires confirmed_outcome_reinforcement" + "confirmation_decay_ratio requires confirmed-only or soft-start " + "feedback reinforcement" + ) + if self.soft_start_feedback_reinforcement: + if self.relation_feedback_evidence_quorum != 1: + raise ValueError( + "soft-start feedback reinforcement cannot be combined with a " + "hard evidence quorum" + ) + if ( + isinstance(self.soft_start_feedback_ratio, bool) + or not isinstance(self.soft_start_feedback_ratio, (int, float)) + or not math.isfinite(float(self.soft_start_feedback_ratio)) + or not 0.0 < float(self.soft_start_feedback_ratio) < 1.0 + ): + raise ValueError( + "soft_start_feedback_ratio must be explicitly set between 0 and 1" + ) + elif self.soft_start_feedback_ratio is not None: + raise ValueError( + "soft_start_feedback_ratio requires soft_start_feedback_reinforcement" ) @@ -197,21 +234,83 @@ def record_success( evidence, ) + def record_soft_start( + self, + trace_id: str, + used_node_ids: Iterable[str], + *, + now: datetime | float | None = None, + ) -> FeedbackReceipt: + """Apply the one-time provisional part of a soft-start relation schedule.""" + if not self.config.soft_start_feedback_reinforcement: + raise ValueError("soft-start feedback reinforcement is not enabled") + ordered_node_ids = tuple(dict.fromkeys(used_node_ids)) + if not ordered_node_ids: + raise ValueError("At least one used node is required") + timestamp = self._timestamp(now) + plan = self._relation_feedback_plan( + trace_id, ordered_node_ids, require_candidate_marker=False + ) + feedback_id = uuid.uuid4().hex + stored_reinforced = self.store.apply_soft_start_feedback( + feedback_id, + trace_id, + timestamp, + ordered_node_ids, + plan["updates"], + soft_start_ratio=float(self.config.soft_start_feedback_ratio), + decay_ratio=float(self.config.confirmation_decay_ratio), + ) + return FeedbackReceipt( + feedback_id, + trace_id, + ordered_node_ids, + tuple( + ReinforcedEdge( + source_id, + target_id, + edge_type, + old_weight, + new_weight, + ) + for source_id, target_id, edge_type, old_weight, new_weight in stored_reinforced + ), + self.store.retrieval_channel(trace_id), + ) + def confirmed_outcome_plan( self, trace_id: str, used_node_ids: Iterable[str] ) -> dict[str, Any]: """Build a relation-only credited plan for an opt-in confirmed outcome.""" - if not self.config.confirmed_outcome_reinforcement: + if not ( + self.config.confirmed_outcome_reinforcement + or self.config.soft_start_feedback_reinforcement + ): raise ValueError("confirmed outcome reinforcement is not enabled") + return self._relation_feedback_plan( + trace_id, used_node_ids, require_candidate_marker=True + ) + + def _relation_feedback_plan( + self, + trace_id: str, + used_node_ids: Iterable[str], + *, + require_candidate_marker: bool, + ) -> dict[str, Any]: ordered_node_ids = tuple(dict.fromkeys(used_node_ids)) if not ordered_node_ids: raise ValueError("At least one used node is required") if self.store.retrieval_channel(trace_id) != "relation": return {"updates": (), "normalization_sets": (), "credited_paths": ()} - eligible_node_ids = tuple( - node_id - for node_id in ordered_node_ids - if self.store.is_confirmed_candidate_use(trace_id, node_id) + eligible_node_ids = ( + tuple( + node_id + for node_id in ordered_node_ids + if self.store.is_confirmed_candidate_use(trace_id, node_id) + ) + if require_candidate_marker + else ordered_node_ids ) selected_paths: list[tuple[str, dict[str, object]]] = [] diff --git a/src/neuron_graph_rag/feedback.py b/src/neuron_graph_rag/feedback.py index b6084f6..9dac782 100644 --- a/src/neuron_graph_rag/feedback.py +++ b/src/neuron_graph_rag/feedback.py @@ -54,9 +54,22 @@ def record_source_use( separators=(",", ":"), ensure_ascii=False, ) - reinforce_on_use = not bool( + confirmed_only = bool( getattr(self.engine.config, "confirmed_outcome_reinforcement", False) ) + soft_start = bool( + getattr(self.engine.config, "soft_start_feedback_reinforcement", False) + ) + if soft_start: + apply_feedback = lambda node_ids: self.engine.record_soft_start( + trace_id, node_ids, now=timestamp + ) + elif confirmed_only: + apply_feedback = None + else: + apply_feedback = lambda node_ids: self.engine.record_success( + trace_id, node_ids, now=timestamp + ) stored = self.engine.store.record_source_use( idempotency_key=idempotency_key, payload_json=payload_json, @@ -64,16 +77,8 @@ def record_source_use( trace_id=trace_id, created_at=timestamp, events=tuple((event.node_id, event.stage) for event in ordered_events), - apply_feedback=( - ( - lambda node_ids: self.engine.record_success( - trace_id, node_ids, now=timestamp - ) - ) - if reinforce_on_use - else None - ), - confirmation_candidate=not reinforce_on_use, + apply_feedback=apply_feedback, + confirmation_candidate=confirmed_only or soft_start, ) feedback_data = stored["feedback"] feedback = None @@ -162,10 +167,25 @@ def record_outcome( recorded_at = self._timestamp(now) candidate_enabled = bool( getattr(self.engine.config, "confirmed_outcome_reinforcement", False) + or getattr(self.engine.config, "soft_start_feedback_reinforcement", False) ) if candidate_enabled and outcome == "confirmed": plan = self.engine.confirmed_outcome_plan(trace_id, ordered_node_ids) - stored = self.engine.store.record_confirmed_outcome( + record_confirmed = ( + self.engine.store.record_soft_start_confirmed_outcome + if getattr( + self.engine.config, "soft_start_feedback_reinforcement", False + ) + else self.engine.store.record_confirmed_outcome + ) + candidate_options = ( + {"soft_start_ratio": float(self.engine.config.soft_start_feedback_ratio)} + if getattr( + self.engine.config, "soft_start_feedback_reinforcement", False + ) + else {} + ) + stored = record_confirmed( idempotency_key=idempotency_key, payload_json=payload_json, outcome_id=outcome_id, @@ -178,6 +198,7 @@ def record_outcome( edge_updates=plan["updates"], normalization_sets=plan["normalization_sets"], credited_paths=plan["credited_paths"], + **candidate_options, ) else: stored = self.engine.store.record_outcome( diff --git a/src/neuron_graph_rag/storage.py b/src/neuron_graph_rag/storage.py index b9ffa88..1d5ac09 100644 --- a/src/neuron_graph_rag/storage.py +++ b/src/neuron_graph_rag/storage.py @@ -186,6 +186,38 @@ def _create_schema(self) -> None: FOREIGN KEY (source_id, target_id, edge_type) REFERENCES edges(source_id, target_id, edge_type) ON DELETE CASCADE ); + + CREATE TABLE IF NOT EXISTS soft_start_edge_state ( + source_id TEXT NOT NULL, + target_id TEXT NOT NULL, + edge_type TEXT NOT NULL, + confirmation_count INTEGER NOT NULL CHECK(confirmation_count >= 0), + base_increment REAL NOT NULL CHECK(base_increment > 0.0), + initial_weight REAL NOT NULL CHECK(initial_weight >= 0.0), + soft_start_ratio REAL NOT NULL + CHECK(soft_start_ratio > 0.0 AND soft_start_ratio < 1.0), + decay_ratio REAL NOT NULL CHECK(decay_ratio > 0.0 AND decay_ratio < 1.0), + geometric_maximum REAL NOT NULL CHECK(geometric_maximum >= initial_weight), + PRIMARY KEY (source_id, target_id, edge_type), + FOREIGN KEY (source_id, target_id, edge_type) + REFERENCES edges(source_id, target_id, edge_type) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS soft_start_relation_feedback ( + source_id TEXT NOT NULL, + target_id TEXT NOT NULL, + edge_type TEXT NOT NULL, + trace_id TEXT NOT NULL REFERENCES retrievals(trace_id) ON DELETE CASCADE, + feedback_id TEXT NOT NULL + REFERENCES success_feedback(feedback_id) ON DELETE CASCADE, + actual_delta REAL NOT NULL CHECK(actual_delta >= 0.0), + old_weight REAL NOT NULL CHECK(old_weight >= 0.0), + new_weight REAL NOT NULL CHECK(new_weight >= old_weight), + created_at REAL NOT NULL, + PRIMARY KEY (source_id, target_id, edge_type, trace_id), + FOREIGN KEY (source_id, target_id, edge_type) + REFERENCES edges(source_id, target_id, edge_type) ON DELETE CASCADE + ); """ ) self.connection.commit() @@ -691,6 +723,169 @@ def apply_evidence_gated_success_feedback( ) return reinforced, normalized, evidence + def apply_soft_start_feedback( + self, + feedback_id: str, + trace_id: str, + created_at: float, + used_node_ids: Iterable[str], + edge_updates: Iterable[tuple[str, str, str, float, float]], + *, + soft_start_ratio: float, + decay_ratio: float, + ) -> list[tuple[str, str, str, float, float]]: + """Atomically apply the first provisional update for each credited edge.""" + updates = tuple(edge_updates) + reinforced: list[tuple[str, str, str, float, float]] = [] + with self.transaction() as connection: + self._require_trace(connection, trace_id) + connection.execute( + """ + INSERT INTO success_feedback(feedback_id, trace_id, created_at) + VALUES (?, ?, ?) + """, + (feedback_id, trace_id, created_at), + ) + for node_id in used_node_ids: + exists = connection.execute( + """ + SELECT 1 FROM retrieval_results + WHERE trace_id = ? AND node_id = ? + """, + (trace_id, node_id), + ).fetchone() + if exists is None: + raise ValueError( + f"Successful node {node_id} was not retrieved by trace {trace_id}" + ) + connection.execute( + "INSERT INTO success_nodes(feedback_id, node_id) VALUES (?, ?)", + (feedback_id, node_id), + ) + for source_id, target_id, edge_type, base_increment, maximum in updates: + duplicate = connection.execute( + """ + SELECT 1 FROM soft_start_relation_feedback + WHERE source_id = ? AND target_id = ? AND edge_type = ? AND trace_id = ? + """, + (source_id, target_id, edge_type, trace_id), + ).fetchone() + if duplicate is not None: + continue + edge = connection.execute( + """ + SELECT weight FROM edges + WHERE source_id = ? AND target_id = ? AND edge_type = ? + """, + (source_id, target_id, edge_type), + ).fetchone() + if edge is None: + raise KeyError( + f"Unknown edge: {source_id} -> {target_id} ({edge_type})" + ) + old_weight = float(edge["weight"]) + confirmed_state = connection.execute( + """ + SELECT 1 FROM confirmed_edge_state + WHERE source_id = ? AND target_id = ? AND edge_type = ? + """, + (source_id, target_id, edge_type), + ).fetchone() + if confirmed_state is not None: + raise FeedbackContractError( + "confirmation_policy_conflict", + "soft-start cannot replace a persisted confirmed-only edge schedule", + ) + state = connection.execute( + """ + SELECT * FROM soft_start_edge_state + WHERE source_id = ? AND target_id = ? AND edge_type = ? + """, + (source_id, target_id, edge_type), + ).fetchone() + actual_delta = 0.0 + new_weight = old_weight + if state is None: + geometric_maximum = min( + maximum, old_weight + base_increment / (1.0 - decay_ratio) + ) + new_weight = max( + old_weight, + min( + maximum, + geometric_maximum, + old_weight + base_increment * soft_start_ratio, + ), + ) + actual_delta = new_weight - old_weight + connection.execute( + """ + UPDATE edges + SET weight = ?, reinforced_count = reinforced_count + 1 + WHERE source_id = ? AND target_id = ? AND edge_type = ? + """, + (new_weight, source_id, target_id, edge_type), + ) + connection.execute( + """ + INSERT INTO soft_start_edge_state( + source_id, target_id, edge_type, confirmation_count, + base_increment, initial_weight, soft_start_ratio, + decay_ratio, geometric_maximum + ) VALUES (?, ?, ?, 0, ?, ?, ?, ?, ?) + """, + ( + source_id, + target_id, + edge_type, + base_increment, + old_weight, + soft_start_ratio, + decay_ratio, + geometric_maximum, + ), + ) + reinforced.append( + (source_id, target_id, edge_type, old_weight, new_weight) + ) + else: + if not math.isclose( + float(state["soft_start_ratio"]), + soft_start_ratio, + rel_tol=0.0, + abs_tol=1e-15, + ) or not math.isclose( + float(state["decay_ratio"]), + decay_ratio, + rel_tol=0.0, + abs_tol=1e-15, + ): + raise FeedbackContractError( + "confirmation_policy_conflict", + "soft-start ratio or confirmation decay differs from the " + "persisted edge schedule", + ) + connection.execute( + """ + INSERT INTO soft_start_relation_feedback( + source_id, target_id, edge_type, trace_id, feedback_id, + actual_delta, old_weight, new_weight, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + source_id, + target_id, + edge_type, + trace_id, + feedback_id, + actual_delta, + old_weight, + new_weight, + created_at, + ), + ) + return reinforced + def record_source_use( self, *, @@ -892,6 +1087,18 @@ def record_confirmed_outcome( (source_id, target_id, edge_type), ).fetchone() if state is None: + soft_start_state = connection.execute( + """ + SELECT 1 FROM soft_start_edge_state + WHERE source_id = ? AND target_id = ? AND edge_type = ? + """, + (source_id, target_id, edge_type), + ).fetchone() + if soft_start_state is not None: + raise FeedbackContractError( + "confirmation_policy_conflict", + "confirmed-only cannot replace a persisted soft-start edge schedule", + ) confirmation_count = 1 stored_base_increment = base_increment geometric_maximum = min( @@ -1037,6 +1244,229 @@ def record_confirmed_outcome( ) return result + def record_soft_start_confirmed_outcome( + self, + *, + idempotency_key: str, + payload_json: str, + outcome_id: str, + trace_id: str, + node_ids: tuple[str, ...], + summary: str, + external_ref: str | None, + recorded_at: float, + decay_ratio: float, + soft_start_ratio: float, + edge_updates: tuple[tuple[str, str, str, float, float], ...], + normalization_sets: tuple[ + tuple[str, tuple[tuple[str, str, str], ...], float], ... + ], + credited_paths: tuple[dict[str, object], ...], + ) -> dict[str, Any]: + """Atomically record confirmation of a persisted soft-start schedule.""" + payload_hash = hashlib.sha256(payload_json.encode("utf-8")).hexdigest() + confirmations: list[dict[str, Any]] = [] + normalized: list[dict[str, Any]] = [] + with self.transaction() as connection: + replay = self._idempotent_replay( + connection, idempotency_key, "record_outcome", payload_hash + ) + if replay is not None: + return replay + self._require_trace(connection, trace_id) + self._require_used_nodes(connection, trace_id, node_ids) + connection.execute( + """ + INSERT INTO delayed_outcomes( + outcome_id, trace_id, outcome, summary, external_ref, recorded_at + ) VALUES (?, ?, 'confirmed', ?, ?, ?) + """, + (outcome_id, trace_id, summary, external_ref, recorded_at), + ) + for node_id in node_ids: + connection.execute( + "INSERT INTO delayed_outcome_nodes(outcome_id, node_id) VALUES (?, ?)", + (outcome_id, node_id), + ) + + reinforced_increase_by_source: dict[str, float] = {} + for source_id, target_id, edge_type, base_increment, maximum in edge_updates: + duplicate = connection.execute( + """ + SELECT 1 FROM confirmed_relation_feedback + WHERE source_id = ? AND target_id = ? AND edge_type = ? AND trace_id = ? + """, + (source_id, target_id, edge_type, trace_id), + ).fetchone() + if duplicate is not None: + continue + edge = connection.execute( + """ + SELECT weight FROM edges + WHERE source_id = ? AND target_id = ? AND edge_type = ? + """, + (source_id, target_id, edge_type), + ).fetchone() + if edge is None: + raise KeyError( + f"Unknown edge: {source_id} -> {target_id} ({edge_type})" + ) + confirmed_only_state = connection.execute( + """ + SELECT 1 FROM confirmed_edge_state + WHERE source_id = ? AND target_id = ? AND edge_type = ? + """, + (source_id, target_id, edge_type), + ).fetchone() + if confirmed_only_state is not None: + raise FeedbackContractError( + "confirmation_policy_conflict", + "soft-start cannot replace a persisted confirmed-only edge schedule", + ) + state = connection.execute( + """ + SELECT * FROM soft_start_edge_state + WHERE source_id = ? AND target_id = ? AND edge_type = ? + """, + (source_id, target_id, edge_type), + ).fetchone() + if state is None: + continue + if not math.isclose( + float(state["soft_start_ratio"]), + soft_start_ratio, + rel_tol=0.0, + abs_tol=1e-15, + ) or not math.isclose( + float(state["decay_ratio"]), + decay_ratio, + rel_tol=0.0, + abs_tol=1e-15, + ): + raise FeedbackContractError( + "confirmation_policy_conflict", + "soft-start ratio or confirmation decay differs from the " + "persisted edge schedule", + ) + old_weight = float(edge["weight"]) + confirmation_count = int(state["confirmation_count"]) + 1 + stored_base_increment = float(state["base_increment"]) + if confirmation_count == 1: + multiplier = 1.0 - soft_start_ratio + target_weight = min( + maximum, + float(state["geometric_maximum"]), + float(state["initial_weight"]) + stored_base_increment, + ) + new_weight = max(old_weight, target_weight) + else: + multiplier = decay_ratio ** (confirmation_count - 1) + new_weight = max( + old_weight, + min( + maximum, + float(state["geometric_maximum"]), + old_weight + stored_base_increment * multiplier, + ), + ) + actual_delta = new_weight - old_weight + connection.execute( + """ + UPDATE edges + SET weight = ?, reinforced_count = reinforced_count + 1 + WHERE source_id = ? AND target_id = ? AND edge_type = ? + """, + (new_weight, source_id, target_id, edge_type), + ) + connection.execute( + """ + UPDATE soft_start_edge_state SET confirmation_count = ? + WHERE source_id = ? AND target_id = ? AND edge_type = ? + """, + (confirmation_count, source_id, target_id, edge_type), + ) + connection.execute( + """ + INSERT INTO confirmed_relation_feedback( + source_id, target_id, edge_type, trace_id, outcome_id, + confirmation_count, multiplier, actual_delta, + old_weight, new_weight, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + source_id, target_id, edge_type, trace_id, outcome_id, + confirmation_count, multiplier, actual_delta, + old_weight, new_weight, recorded_at, + ), + ) + confirmations.append( + { + "source_id": source_id, + "target_id": target_id, + "edge_type": edge_type, + "confirmation_count": confirmation_count, + "multiplier": multiplier, + "actual_delta": actual_delta, + "old_weight": old_weight, + "new_weight": new_weight, + } + ) + reinforced_increase_by_source[source_id] = ( + reinforced_increase_by_source.get(source_id, 0.0) + actual_delta + ) + + for source_id, sibling_keys, ratio in normalization_sets: + total_increase = reinforced_increase_by_source.get(source_id, 0.0) + if total_increase <= 0.0 or not sibling_keys: + continue + reduction = total_increase * ratio / len(sibling_keys) + for sibling_source, target_id, edge_type in sibling_keys: + row = connection.execute( + """ + SELECT weight FROM edges + WHERE source_id = ? AND target_id = ? AND edge_type = ? + """, + (sibling_source, target_id, edge_type), + ).fetchone() + if row is None: + raise KeyError( + f"Unknown edge: {sibling_source} -> {target_id} ({edge_type})" + ) + old_weight = float(row["weight"]) + new_weight = max(0.0, old_weight - reduction) + if new_weight < old_weight: + connection.execute( + """ + UPDATE edges SET weight = ? + WHERE source_id = ? AND target_id = ? AND edge_type = ? + """, + (new_weight, sibling_source, target_id, edge_type), + ) + normalized.append( + { + "source_id": sibling_source, + "target_id": target_id, + "edge_type": edge_type, + "old_weight": old_weight, + "new_weight": new_weight, + } + ) + result = { + "outcome_id": outcome_id, + "trace_id": trace_id, + "node_ids": list(node_ids), + "outcome": "confirmed", + "recorded_at": recorded_at, + "reinforcement_applied": bool(confirmations), + "confirmations": confirmations, + "credited_paths": list(credited_paths), + "normalized_sibling_edges": normalized, + } + self._save_idempotent_result( + connection, idempotency_key, "record_outcome", payload_hash, result + ) + return result + def record_outcome( self, *, diff --git a/src/neuron_graph_rag_mcp/server.py b/src/neuron_graph_rag_mcp/server.py index c207e46..fcadb71 100644 --- a/src/neuron_graph_rag_mcp/server.py +++ b/src/neuron_graph_rag_mcp/server.py @@ -72,6 +72,22 @@ "idempotency retries do not reinforce twice. Corrected, rolled_back, and superseded " "remain audit-only and never subtract or roll back weights." ) +SOFT_START_SOURCE_USE_DESCRIPTION = ( + "Record ordered source-use transitions for candidates from one Neuron Graph RAG " + "search trace. Use selected, validated, and used in order. This server uses the " + "soft-start candidate: the first newly used relation trace can apply the configured " + "provisional fraction of one bounded update. Later used traces add provenance only; " + "retrieved, selected, validated, retries, and duplicate stages never reinforce. " + "Retain trace_id and record a confirmed outcome when later evidence supports it." +) +SOFT_START_OUTCOME_DESCRIPTION = ( + "Record a delayed outcome for sources already marked used. This server uses the " + "soft-start candidate: the first independent confirmed outcome completes at most " + "the remainder of one normal bounded update, and later independent confirmations " + "use the configured geometric decay. Sibling normalization applies only to each " + "confirmed outcome's actual delta. Duplicate traces and idempotency retries do not " + "reinforce twice; negative outcomes remain audit-only." +) _IDEMPOTENCY = re.compile(r"^[A-Za-z0-9._:-]+$") _TRACE = re.compile(r"^[0-9a-f]{32}$") @@ -340,21 +356,35 @@ def _annotations(*, idempotent: bool) -> types.ToolAnnotations: ) -def _tools(*, confirmed_outcome_reinforcement: bool) -> tuple[types.Tool, ...]: - if not confirmed_outcome_reinforcement: +def _tools( + *, + confirmed_outcome_reinforcement: bool, + soft_start_feedback_reinforcement: bool, +) -> tuple[types.Tool, ...]: + if not confirmed_outcome_reinforcement and not soft_start_feedback_reinforcement: return TOOLS + source_use_description = ( + SOFT_START_SOURCE_USE_DESCRIPTION + if soft_start_feedback_reinforcement + else CONFIRMED_SOURCE_USE_DESCRIPTION + ) + outcome_description = ( + SOFT_START_OUTCOME_DESCRIPTION + if soft_start_feedback_reinforcement + else CONFIRMED_OUTCOME_DESCRIPTION + ) return ( TOOLS[0], types.Tool( name="record_source_use", - description=CONFIRMED_SOURCE_USE_DESCRIPTION, + description=source_use_description, input_schema=SOURCE_USE_INPUT, output_schema=SOURCE_USE_OUTPUT, annotations=_annotations(idempotent=True), ), types.Tool( name="record_outcome", - description=CONFIRMED_OUTCOME_DESCRIPTION, + description=outcome_description, input_schema=OUTCOME_INPUT, output_schema=OUTCOME_OUTPUT, annotations=_annotations(idempotent=True), @@ -371,7 +401,10 @@ def __init__( self.tools = _tools( confirmed_outcome_reinforcement=( self.engine.config.confirmed_outcome_reinforcement - ) + ), + soft_start_feedback_reinforcement=( + self.engine.config.soft_start_feedback_reinforcement + ), ) def close(self) -> None: @@ -797,7 +830,18 @@ def _build_parser() -> argparse.ArgumentParser: "--confirmation-decay-ratio", type=_open_unit_interval, default=None, - help="Geometric decay ratio required by confirmed-outcome reinforcement", + help="Geometric decay ratio required by confirmed-only or soft-start reinforcement", + ) + parser.add_argument( + "--soft-start-feedback-reinforcement", + action="store_true", + help="Apply a provisional used update and complete it on first confirmation", + ) + parser.add_argument( + "--soft-start-feedback-ratio", + type=_open_unit_interval, + default=None, + help="Provisional fraction required by soft-start feedback reinforcement", ) return parser @@ -805,13 +849,39 @@ def _build_parser() -> argparse.ArgumentParser: def main() -> None: parser = _build_parser() arguments = parser.parse_args() - if arguments.confirmed_outcome_reinforcement and arguments.confirmation_decay_ratio is None: + if ( + arguments.confirmed_outcome_reinforcement + and arguments.soft_start_feedback_reinforcement + ): + parser.error( + "--confirmed-outcome-reinforcement and " + "--soft-start-feedback-reinforcement are mutually exclusive" + ) + candidate_enabled = ( + arguments.confirmed_outcome_reinforcement + or arguments.soft_start_feedback_reinforcement + ) + if candidate_enabled and arguments.confirmation_decay_ratio is None: + parser.error( + "confirmed-only and soft-start reinforcement require --confirmation-decay-ratio" + ) + if not candidate_enabled and arguments.confirmation_decay_ratio is not None: parser.error( - "--confirmed-outcome-reinforcement requires --confirmation-decay-ratio" + "--confirmation-decay-ratio requires confirmed-only or soft-start reinforcement" ) - if not arguments.confirmed_outcome_reinforcement and arguments.confirmation_decay_ratio is not None: + if ( + arguments.soft_start_feedback_reinforcement + and arguments.soft_start_feedback_ratio is None + ): parser.error( - "--confirmation-decay-ratio requires --confirmed-outcome-reinforcement" + "--soft-start-feedback-reinforcement requires --soft-start-feedback-ratio" + ) + if ( + not arguments.soft_start_feedback_reinforcement + and arguments.soft_start_feedback_ratio is not None + ): + parser.error( + "--soft-start-feedback-ratio requires --soft-start-feedback-reinforcement" ) config = EngineConfig( relation_feedback_evidence_quorum=( @@ -820,5 +890,9 @@ def main() -> None: sibling_feedback_normalization=arguments.sibling_feedback_normalization, confirmed_outcome_reinforcement=arguments.confirmed_outcome_reinforcement, confirmation_decay_ratio=arguments.confirmation_decay_ratio, + soft_start_feedback_reinforcement=( + arguments.soft_start_feedback_reinforcement + ), + soft_start_feedback_ratio=arguments.soft_start_feedback_ratio, ) asyncio.run(_run(arguments.database, config=config)) diff --git a/tests/test_mcp_adapter.py b/tests/test_mcp_adapter.py index 4020ac4..de6daca 100644 --- a/tests/test_mcp_adapter.py +++ b/tests/test_mcp_adapter.py @@ -20,6 +20,8 @@ CONTRACT_VERSION, OUTCOME_DESCRIPTION, SEARCH_DESCRIPTION, + SOFT_START_OUTCOME_DESCRIPTION, + SOFT_START_SOURCE_USE_DESCRIPTION, SOURCE_USE_DESCRIPTION, FeedbackMCPAdapter, ) @@ -281,6 +283,103 @@ async def test_confirmed_candidate_moves_reinforcement_to_outcome_with_receipt_p "decision", ) + async def test_soft_start_candidate_receipt_parity_and_provenance(self) -> None: + self.adapter.close() + self.adapter = FeedbackMCPAdapter( + self.database, + config=EngineConfig( + soft_start_feedback_reinforcement=True, + soft_start_feedback_ratio=0.25, + confirmation_decay_ratio=0.5, + sibling_feedback_normalization=1.0, + ), + ) + listed = await self.adapter.list_tools() + self.assertEqual(listed.tools[1].description, SOFT_START_SOURCE_USE_DESCRIPTION) + self.assertEqual(listed.tools[2].description, SOFT_START_OUTCOME_DESCRIPTION) + before = self.adapter.engine.store.edge( + "decision", "implementation", "implemented_by" + ).weight + sibling_before = self.adapter.engine.store.edge( + "decision", "alternate", "implemented_by" + ).weight + search = await self.adapter.call_tool( + None, + types.CallToolRequestParams( + name="search", + arguments={ + "contract_version": CONTRACT_VERSION, + "query": "cache invalidation", + "limit": 3, + }, + ), + ) + provenance = search.structured_content["effective_config_provenance"] + self.assertEqual(provenance["search_surface"], "relation") + feedback_config = provenance["effective_config"]["feedback"] + self.assertTrue(feedback_config["soft_start_feedback_reinforcement"]) + self.assertEqual(feedback_config["soft_start_feedback_ratio"], 0.25) + trace_id = search.structured_content["trace_id"] + source_use = await self.adapter.call_tool( + None, + types.CallToolRequestParams( + name="record_source_use", + arguments={ + "contract_version": CONTRACT_VERSION, + "idempotency_key": "soft-start-use", + "trace_id": trace_id, + "events": [ + {"node_id": "implementation", "stage": "selected"}, + {"node_id": "implementation", "stage": "validated"}, + {"node_id": "implementation", "stage": "used"}, + ], + }, + ), + ) + feedback = source_use.structured_content["feedback"] + self.assertIsNotNone(feedback) + provisional = feedback["reinforced_edges"][0] + provisional_delta = provisional["new_weight"] - provisional["old_weight"] + self.assertGreater(provisional_delta, 0.0) + self.assertEqual( + self.adapter.engine.store.edge( + "decision", "alternate", "implemented_by" + ).weight, + sibling_before, + ) + outcome = await self.adapter.call_tool( + None, + types.CallToolRequestParams( + name="record_outcome", + arguments={ + "contract_version": CONTRACT_VERSION, + "idempotency_key": "soft-start-confirmed", + "trace_id": trace_id, + "node_ids": ["implementation"], + "outcome": "confirmed", + "summary": "the implementation was verified", + }, + ), + ) + confirmation = outcome.structured_content["confirmations"][0] + self.assertEqual(confirmation["confirmation_count"], 1) + self.assertEqual(confirmation["multiplier"], 0.75) + self.assertGreater(confirmation["actual_delta"], 0.0) + self.assertAlmostEqual( + self.adapter.engine.store.edge( + "decision", "implementation", "implemented_by" + ).weight + - before, + provisional_delta + confirmation["actual_delta"], + ) + self.assertAlmostEqual( + sibling_before + - self.adapter.engine.store.edge( + "decision", "alternate", "implemented_by" + ).weight, + confirmation["actual_delta"], + ) + async def test_stdio_protocol_smoke(self) -> None: parameters = StdioServerParameters( command=sys.executable, @@ -428,6 +527,10 @@ def test_invalid_cli_feedback_settings_do_not_create_database(self) -> None: ("--sibling-feedback-normalization", "nan"), ("--confirmation-decay-ratio", "0"), ("--confirmation-decay-ratio", "1"), + ("--soft-start-feedback-ratio", "0"), + ("--soft-start-feedback-ratio", "1"), + ("--soft-start-feedback-ratio", "nan"), + ("--soft-start-feedback-ratio", "inf"), ) for index, (option, value) in enumerate(invalid_values): with self.subTest(option=option, value=value): @@ -471,6 +574,38 @@ def test_invalid_cli_feedback_settings_do_not_create_database(self) -> None: self.assertEqual(result.returncode, 2) self.assertFalse(missing_pair.exists()) + invalid_combinations = ( + ("--soft-start-feedback-reinforcement", "--confirmation-decay-ratio", "0.5"), + ("--soft-start-feedback-ratio", "0.25"), + ( + "--confirmed-outcome-reinforcement", + "--soft-start-feedback-reinforcement", + "--soft-start-feedback-ratio", + "0.25", + "--confirmation-decay-ratio", + "0.5", + ), + ) + for index, options in enumerate(invalid_combinations): + with self.subTest(options=options): + database = Path(self.temporary.name) / f"invalid-combination-{index}.sqlite" + result = subprocess.run( + [ + sys.executable, + "-m", + "neuron_graph_rag_mcp", + "--database", + str(database), + *options, + ], + capture_output=True, + text=True, + timeout=10, + check=False, + ) + self.assertEqual(result.returncode, 2) + self.assertFalse(database.exists()) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_soft_start_feedback.py b/tests/test_soft_start_feedback.py new file mode 100644 index 0000000..711fc40 --- /dev/null +++ b/tests/test_soft_start_feedback.py @@ -0,0 +1,342 @@ +from __future__ import annotations + +import math +import sqlite3 +import tempfile +import unittest +from contextlib import closing +from pathlib import Path +from unittest.mock import patch + +from neuron_graph_rag import FeedbackLedger, SourceUseEvent +from neuron_graph_rag.config_provenance import effective_config_provenance +from neuron_graph_rag.evidence_feedback import EngineConfig, NeuronGraphRAG + + +def _config(*, sibling_ratio: float = 0.0, soft_ratio: float = 0.25) -> EngineConfig: + return EngineConfig( + sparse_weight=1.0, + dense_weight=0.0, + seed_count=1, + max_hops=2, + feedback_learning_rate=0.2, + sibling_feedback_normalization=sibling_ratio, + soft_start_feedback_reinforcement=True, + soft_start_feedback_ratio=soft_ratio, + confirmation_decay_ratio=0.5, + ) + + +def _populate(engine: NeuronGraphRAG) -> None: + engine.add_document("source", "alpha lexical source") + engine.add_document("target", "distant relation target") + engine.add_document("sibling", "uncredited sibling") + engine.add_document("other-source", "isolated origin") + engine.add_document("other-target", "isolated destination") + engine.add_edge("source", "target", "supports", weight=0.5) + engine.add_edge("source", "sibling", "supports", weight=0.4) + engine.add_edge("other-source", "other-target", "isolated", weight=0.8) + + +def _used_relation(engine: NeuronGraphRAG, index: int) -> tuple[FeedbackLedger, str, object]: + ledger = FeedbackLedger(engine) + trace = engine.search_channels("alpha", limit=5, now=1_000.0 + index).relation + receipt = ledger.record_source_use( + trace.trace_id, + [ + SourceUseEvent("target", "selected"), + SourceUseEvent("target", "validated"), + SourceUseEvent("target", "used"), + ], + idempotency_key=f"soft-use-{index}", + now=2_000.0 + index, + ) + return ledger, trace.trace_id, receipt + + +class SoftStartFeedbackTest(unittest.TestCase): + def test_config_is_default_off_and_rejects_incompatible_values(self) -> None: + default = EngineConfig() + self.assertFalse(default.soft_start_feedback_reinforcement) + self.assertIsNone(default.soft_start_feedback_ratio) + default_feedback = effective_config_provenance(default)["effective_config"][ + "feedback" + ] + self.assertNotIn("soft_start_feedback_reinforcement", default_feedback) + self.assertNotIn("soft_start_feedback_ratio", default_feedback) + active_feedback = effective_config_provenance(_config())["effective_config"][ + "feedback" + ] + self.assertTrue(active_feedback["soft_start_feedback_reinforcement"]) + self.assertEqual(active_feedback["soft_start_feedback_ratio"], 0.25) + for ratio in (0.0, 1.0, math.nan, math.inf, True): + with self.subTest(ratio=ratio), self.assertRaises(ValueError): + EngineConfig( + soft_start_feedback_reinforcement=True, + soft_start_feedback_ratio=ratio, + confirmation_decay_ratio=0.5, + ) + with self.assertRaises(ValueError): + EngineConfig(soft_start_feedback_ratio=0.25) + with self.assertRaises(ValueError): + EngineConfig( + soft_start_feedback_reinforcement=True, + soft_start_feedback_ratio=0.25, + ) + with self.assertRaises(ValueError): + EngineConfig( + confirmed_outcome_reinforcement=True, + soft_start_feedback_reinforcement=True, + soft_start_feedback_ratio=0.25, + confirmation_decay_ratio=0.5, + ) + with self.assertRaises(ValueError): + EngineConfig( + relation_feedback_evidence_quorum=2, + soft_start_feedback_reinforcement=True, + soft_start_feedback_ratio=0.25, + confirmation_decay_ratio=0.5, + ) + + def test_used_provisional_then_confirmed_remainder_and_decay(self) -> None: + with NeuronGraphRAG(config=_config(sibling_ratio=1.0)) as engine: + _populate(engine) + target_initial = engine.store.edge("source", "target", "supports") + sibling_initial = engine.store.edge("source", "sibling", "supports") + ledger, trace_id, used = _used_relation(engine, 1) + self.assertIsNotNone(used.feedback) + self.assertEqual(len(used.feedback.reinforced_edges), 1) + provisional_delta = ( + used.feedback.reinforced_edges[0].new_weight + - used.feedback.reinforced_edges[0].old_weight + ) + self.assertGreater(provisional_delta, 0.0) + self.assertEqual( + engine.store.edge("source", "sibling", "supports"), sibling_initial + ) + first = ledger.record_outcome( + trace_id, + ["target"], + "confirmed", + "first confirmation", + idempotency_key="soft-confirm-1", + ) + base_increment = provisional_delta / 0.25 + self.assertEqual(first.confirmations[0].confirmation_count, 1) + self.assertEqual(first.confirmations[0].multiplier, 0.75) + self.assertAlmostEqual( + provisional_delta + first.confirmations[0].actual_delta, + base_increment, + ) + self.assertAlmostEqual( + sibling_initial.weight + - engine.store.edge("source", "sibling", "supports").weight, + first.confirmations[0].actual_delta, + ) + + multipliers = [first.confirmations[0].multiplier] + for index in (2, 3): + ledger, next_trace, next_used = _used_relation(engine, index) + self.assertIsNotNone(next_used.feedback) + self.assertEqual(next_used.feedback.reinforced_edges, ()) + confirmed = ledger.record_outcome( + next_trace, + ["target"], + "confirmed", + f"confirmation {index}", + idempotency_key=f"soft-confirm-{index}", + ) + multipliers.append(confirmed.confirmations[0].multiplier) + self.assertEqual(multipliers, [0.75, 0.5, 0.25]) + self.assertEqual( + engine.store.edge("other-source", "other-target", "isolated").weight, + 0.8, + ) + self.assertGreater( + engine.store.edge("source", "target", "supports").weight, + target_initial.weight, + ) + + def test_duplicates_negative_outcomes_and_nonrelation_paths_are_nonmutating(self) -> None: + with NeuronGraphRAG(config=_config()) as engine: + _populate(engine) + ledger, trace_id, used = _used_relation(engine, 1) + replay = ledger.record_source_use( + trace_id, + [ + SourceUseEvent("target", "selected"), + SourceUseEvent("target", "validated"), + SourceUseEvent("target", "used"), + ], + idempotency_key="soft-use-1", + ) + self.assertEqual(replay, used) + before_negative = engine.store.list_edges() + negative = ledger.record_outcome( + trace_id, + ["target"], + "corrected", + "not supported", + idempotency_key="soft-negative", + ) + self.assertFalse(negative.reinforcement_applied) + self.assertEqual(engine.store.list_edges(), before_negative) + + direct = engine.search("alpha", limit=5, now=4_000.0) + lexical_used = ledger.record_source_use( + direct.trace_id, + [ + SourceUseEvent("source", "selected"), + SourceUseEvent("source", "validated"), + SourceUseEvent("source", "used"), + ], + idempotency_key="soft-lexical-use", + ) + self.assertIsNotNone(lexical_used.feedback) + self.assertEqual(lexical_used.feedback.reinforced_edges, ()) + lexical_confirmed = ledger.record_outcome( + direct.trace_id, + ["source"], + "confirmed", + "direct source", + idempotency_key="soft-lexical-confirm", + ) + self.assertFalse(lexical_confirmed.reinforcement_applied) + self.assertEqual(engine.store.list_edges(), before_negative) + + def test_restart_migration_and_atomic_failures(self) -> None: + with tempfile.TemporaryDirectory() as directory: + database = Path(directory) / "soft-start.sqlite" + with NeuronGraphRAG(database) as legacy: + _populate(legacy) + _, legacy_trace_id, _ = _used_relation(legacy, 0) + before = legacy.store.list_edges() + with NeuronGraphRAG(database, config=_config()) as engine: + self.assertEqual(engine.store.list_edges(), before) + legacy_confirmation = FeedbackLedger(engine).record_outcome( + legacy_trace_id, + ["target"], + "confirmed", + "pre-policy use remains audit-only", + idempotency_key="soft-pre-policy-confirm", + ) + self.assertFalse(legacy_confirmation.reinforcement_applied) + self.assertEqual(engine.store.list_edges(), before) + with closing(sqlite3.connect(database)) as connection: + tables = { + row[0] + for row in connection.execute( + "SELECT name FROM sqlite_master WHERE type = 'table'" + ) + } + self.assertIn("soft_start_edge_state", tables) + self.assertIn("soft_start_relation_feedback", tables) + + ledger = FeedbackLedger(engine) + failing_use_trace = engine.search_channels( + "alpha", limit=5, now=900.0 + ).relation + before_use_failure = engine.store.edge( + "source", "target", "supports" + ) + with ( + patch.object( + engine.store, + "_save_idempotent_result", + side_effect=RuntimeError("injected use receipt failure"), + ), + self.assertRaisesRegex(RuntimeError, "injected use receipt failure"), + ): + ledger.record_source_use( + failing_use_trace.trace_id, + [ + SourceUseEvent("target", "selected"), + SourceUseEvent("target", "validated"), + SourceUseEvent("target", "used"), + ], + idempotency_key="soft-atomic-use", + ) + self.assertEqual( + engine.store.edge("source", "target", "supports"), + before_use_failure, + ) + + ledger, trace_id, _ = _used_relation(engine, 1) + before_failure = engine.store.edge("source", "target", "supports") + with ( + patch.object( + engine.store, + "_save_idempotent_result", + side_effect=RuntimeError("injected receipt failure"), + ), + self.assertRaisesRegex(RuntimeError, "injected receipt failure"), + ): + ledger.record_outcome( + trace_id, + ["target"], + "confirmed", + "must roll back", + idempotency_key="soft-atomic-confirm", + ) + self.assertEqual( + engine.store.edge("source", "target", "supports"), before_failure + ) + self.assertEqual(engine.store.count_outcomes(), 1) + + def test_maximum_weight_and_restart_preserve_the_schedule(self) -> None: + with tempfile.TemporaryDirectory() as directory: + database = Path(directory) / "soft-start-restart.sqlite" + config = _config() + with NeuronGraphRAG(database, config=config) as engine: + _populate(engine) + ledger, trace_id, _ = _used_relation(engine, 1) + first = ledger.record_outcome( + trace_id, + ["target"], + "confirmed", + "first persisted confirmation", + idempotency_key="soft-persisted-first", + ) + self.assertEqual(first.confirmations[0].confirmation_count, 1) + with NeuronGraphRAG(database, config=config) as reopened: + ledger, trace_id, _ = _used_relation(reopened, 2) + second = ledger.record_outcome( + trace_id, + ["target"], + "confirmed", + "second persisted confirmation", + idempotency_key="soft-persisted-second", + ) + self.assertEqual(second.confirmations[0].confirmation_count, 2) + self.assertEqual(second.confirmations[0].multiplier, 0.5) + + capped = EngineConfig( + sparse_weight=1.0, + dense_weight=0.0, + seed_count=1, + max_hops=2, + feedback_learning_rate=0.2, + maximum_edge_weight=0.51, + soft_start_feedback_reinforcement=True, + soft_start_feedback_ratio=0.25, + confirmation_decay_ratio=0.5, + ) + with NeuronGraphRAG(config=capped) as engine: + _populate(engine) + ledger, trace_id, used = _used_relation(engine, 1) + after_used = engine.store.edge("source", "target", "supports").weight + self.assertLessEqual(after_used, 0.51) + confirmed = ledger.record_outcome( + trace_id, + ["target"], + "confirmed", + "capped confirmation", + idempotency_key="soft-capped-confirm", + ) + self.assertGreaterEqual(confirmed.confirmations[0].new_weight, after_used) + self.assertLessEqual(confirmed.confirmations[0].new_weight, 0.51) + self.assertIsNotNone(used.feedback) + + +if __name__ == "__main__": + unittest.main()