Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,14 @@ production D1から取得した新しいdevelopment / holdoutは、旧developmen

freeze後のdevelopmentでは、queryなしのlocal variantsがrelationを改善した一方でdirect / negative-controlを退行させ、query variantsはnegative-controlを維持した一方でbest prior recurrentのrelationを上回れませんでした。候補gate通過は0件だったためholdoutは開かず、既定strategyは`current_positive_additive`のままです。

## Anchored BM25 and graph hybrid experiment

entry retrievalを競合外のzero-hop anchorとして保持し、graph scoreを1 edge以上通過したmessageだけに限定するanchored local strategyを追加しました。BM25-onlyはdense encoderとgraph traversalを呼ばない真のablationです。

production D1からread-only取得した新しい5-node development / holdoutは、過去4 fixturesの39 doc pathsおよび相互間から分離しています。固定した6 variants、raw / normalized score trace、contamination audit、候補gate、one-time holdout停止規則は[Anchored BM25 and graph hybrid experiment](docs/anchored-bm25-graph-hybrid-experiment.md)を参照してください。

freeze後のdevelopmentでは、anchored 3 variantsがrelation MRRを`current`の0.5000から0.7500–1.0000へ改善しましたが、direct lookupとnegative-controlがともに退行しました。候補gate通過は0件だったためholdoutは開かず、既定strategyは`current_positive_additive`のままです。

## Public API

```python
Expand Down Expand Up @@ -197,8 +205,13 @@ MCP 対応 AI との接続は、コアへ MCP SDK を追加せず、同一 repos
- dense score
- entry score
- raw graph activation
- normalized graph activation
- raw / normalized BM25 score
- raw / normalized dense score
- competition前後のentry anchor
- final score
- seed node
- zero-hop / graph path種別
- path contribution
- path 上の edge type、weight、factuality

Expand Down
95 changes: 95 additions & 0 deletions docs/anchored-bm25-graph-hybrid-experiment.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
# Anchored BM25 and graph hybrid experiment

## 目的

現行の正方向加算はentry seedのzero-hop値をgraph activationにも含めるため、入口信号とgraph由来信号を分離できない。本実験はentry retrievalを競合外のresidual anchorとして保持し、1 edge以上を通過したmessageだけをlocal graph signalとして統合した場合に、relation retrievalを改善しながらdirect lookupとnegative controlを維持できるか検証する。

既定は`current_positive_additive`のままにする。未観測holdoutで固定gateを通過するまでは変更しない。

## 凍結artifact

- manifest: `tests/fixtures/d1_liplus_anchored_hybrid_experiment.manifest.json`
- development fixture / gold / provenance: `d1_liplus_anchored_hybrid_development.*.json`
- holdout fixture / gold / provenance: `d1_liplus_anchored_hybrid_holdout.*.json`
- contamination audit: `d1_liplus_anchored_hybrid.contamination.json`

developmentはbrake運用clusterの5 nodes / 5 edges、holdoutはDecision Structure clusterの5 nodes / 4 edgesである。両方ともproduction D1へSELECT / WITHだけを実行して取得し、provenanceの全queryで`rows_written=0`、`changes=0`、`changed_db=false`を確認する。

## contamination境界

auditは次の4 prior fixturesからfixture identifierだけを読む。

1. 初回12-node benchmark development
2. neural dynamicsの開封済み9-node holdout
3. local competitionの9-node development
4. local competitionの未開封9-node holdout

合計39 doc pathsをdenylistとし、新development / holdoutとのdoc path、node ID、source URL、relation endpoint重複を拒否する。新しい両split間では、さらにnormalized queryとexpected nodeも分離する。prior goldとprior resultは読み込まない。

## score契約

各traceはBM25とdenseのraw / normalized score、競合前後のentry anchor、graphのraw / normalized activation、final scoreを記録する。pathは`entry_zero_hop`と`graph`を区別する。

`anchored_local_competition`と`anchored_local_query_competition`では、local competitionの初期seedをmessage生成に使うが、最終graph signalから反復decay後のzero-hop seed residualとzero-hop pathを除く。entry anchorは競合処理に渡した値から変更しない。

BM25-onlyでは`use_dense_retrieval=false`と`use_graph_propagation=false`を使い、重みを0にするだけでなくdense encoderとgraph traversal自体を呼ばない。

## 固定variants

最大数と実数を6に固定し、parameter gridを追加しない。

1. `current`: BM25+dense entry + zero-hopを含む現行positive-additive graph
2. `bm25-only`: BM25 entryのみ
3. `bm25-graph-additive`: BM25 entry + 現行positive-additive graph
4. `anchored-local`: BM25+dense anchor + queryなしlocal edge-only graph
5. `anchored-local-query`: BM25+dense anchor + query-conditioned local edge-only graph
6. `bm25-anchored-local`: BM25 anchor + queryなしlocal edge-only graph

## development選択規則

candidateは次の全条件を満たす必要がある。

- relation MRRが`current`より厳密に高い
- direct lookup MRRが`current`から退行しない
- negative-control MRRが`current`から退行しない
- 全relation caseの期待pathが一致する
- success feedbackがcredited path外のedgeと非対象rankを変更しない
- 全caseでentry anchorが競合前後に不変である
- graph signalにzero-hop pathを含まない

複数候補はrelation MRR、worst-cohort MRR、平均展開数、構造複雑度、variant IDの順で一意に選ぶ。候補がなければholdoutを開かず、既定を維持する。

## holdout停止規則

development候補がある場合だけ、holdoutで`current`、`bm25-only`、選択候補を一度評価する。development / holdout resultは既存fileを上書きしない。採用には同じgateを要求し、一つでも失敗すれば`current_positive_additive`を維持する。

fixture、gold、provenance、audit、manifest、variant parameters、threshold、selection rule、stop ruleはfreeze commitをpushするまでrunnerへ渡さない。観測後の変更や再実行は禁止する。

## 実行手順

freeze commitのpush後に次を一度だけ実行する。

```powershell
uv run python tools/run_dynamics_experiment.py development `
--manifest tests/fixtures/d1_liplus_anchored_hybrid_experiment.manifest.json `
--output tests/fixtures/d1_liplus_anchored_hybrid_experiment.development.result.json
```

候補がある場合だけholdoutを一度実行し、候補がなければholdout resultを作成しない。

## 観測結果

fixture / gold / provenance / audit / manifest / implementation / tests / 規則をfreeze commit `6400466`としてpushした後、developmentを一度だけ実行した。

| variant | direct MRR | relation MRR | negative MRR | gate |
| --- | ---: | ---: | ---: | --- |
| `current` | 1.0000 | 0.5000 | 1.0000 | baseline |
| `bm25-only` | 1.0000 | 0.2917 | 1.0000 | relation非改善、path / feedbackなし |
| `bm25-graph-additive` | 1.0000 | 0.5000 | 1.0000 | relation非改善、zero-hopを含む |
| `anchored-local` | 0.4167 | 1.0000 | 0.7500 | direct / negative退行 |
| `anchored-local-query` | 0.4167 | 1.0000 | 0.7500 | direct / negative退行 |
| `bm25-anchored-local` | 0.5000 | 0.7500 | 0.7500 | direct / negative退行 |

anchored 3 variantsは全relation path、feedback isolation、entry anchor invariant、edge-only graph signalを満たし、relation MRRを厳密に改善した。しかしdirect lookupとnegative-controlの両方が`current`から退行したため、固定gateを通る候補は0件だった。

selectionは`current`、理由は`no_anchored_variant_passed_frozen_gate`である。停止規則に従ってholdoutは開封せず、holdout resultを作成しない。defaultは`current_positive_additive`のままとする。
9 changes: 9 additions & 0 deletions docs/requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,14 @@
39. development候補はrelation MRRで両baselineを厳密に上回り、direct / negative-control MRRがcurrentから退行せず、全relation pathとfeedback isolationを満たす場合だけ選択する。
40. development候補がある場合だけ、未観測holdoutで`current`、`recurrent-balanced`、選択候補を一度評価する。resultの再実行と上書きを拒否する。
41. local recurrent strategyは未観測holdoutで同じgateを通過した場合だけ既定候補になり、それ以外では`current_positive_additive`を維持する。
42. entry retrievalはgraph競合の外側にzero-hop anchorとして保持でき、競合前後で同一値であることをtrace diagnosticsで検証できる。
43. anchored graph signalは少なくとも1 edgeを通ったmessageだけで構成し、zero-hop seed residualとzero-hop pathを含めない。
44. dense retrievalとgraph propagationを独立に無効化でき、BM25-only ablationではdense encoderとgraph traversalを実行しない。
45. explanationはBM25 / denseのraw・normalized値、競合前後のentry anchor、graphのraw・normalized値、final score、zero-hop / graph path種別を区別して保持する。
46. anchored hybrid experimentはproduction D1からread-only取得した新development / holdoutを、過去39 doc pathsを含む4 fixturesおよび相互間で分離し、provenanceとcontamination auditを結果観測前に固定する。
47. anchored hybrid experimentは`current`、`bm25-only`、BM25+現行graph、BM25+dense anchorのlocal/query local、BM25 anchorのlocalを合わせた6 variantsだけを比較する。
48. development候補はrelation MRRがcurrentを厳密に上回り、direct / negative-controlが退行せず、relation path、feedback isolation、anchor invariant、edge-only graph signalをすべて満たす場合だけ選択する。
49. 候補がある場合だけ未観測holdoutで`current`、`bm25-only`、選択候補を一度評価し、同じgateを通過した場合だけdefault変更候補とする。

## 4. Constraints

Expand All @@ -79,3 +87,4 @@
- [Real-corpus benchmark](real-corpus-benchmark.md) が gold freeze、判定規則、観測結果、外挿限界を定義する。
- [Neural dynamics experiment](neural-dynamics-experiment.md) が development / holdout 分離、固定探索空間、候補選択、単一 holdout 開封、停止規則を定義する。
- [Local recurrent competition experiment](neural-dynamics-local-competition-experiment.md) が新規D1 subgraph、contamination audit、query / path ablation、二baseline gateを定義する。
- [Anchored BM25 and graph hybrid experiment](anchored-bm25-graph-hybrid-experiment.md) がzero-hop anchor、edge-only graph signal、BM25 ablation、新規D1 split、単一holdout gateを定義する。
41 changes: 40 additions & 1 deletion src/neuron_graph_rag/dynamics.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,15 +96,20 @@ def propagate(
"local_neighbor_query_competition",
"local_neighbor_path_competition",
"local_neighbor_query_path_competition",
"anchored_local_competition",
"anchored_local_query_competition",
}:
return _local_recurrent(
result = _local_recurrent(
query=query,
seed_ids=seed_ids,
entry=entry,
nodes=nodes,
outgoing_edges=outgoing_edges,
settings=settings,
)
if settings.strategy.startswith("anchored_"):
return _without_zero_hop_anchor(result, seed_ids, entry, settings)
return result
if settings.strategy == "recurrent_competition":
return _recurrent(
query=query,
Expand Down Expand Up @@ -372,6 +377,7 @@ def _local_recurrent(
query_conditioned = settings.strategy in {
"local_neighbor_query_competition",
"local_neighbor_query_path_competition",
"anchored_local_query_competition",
}
path_conditioned = settings.strategy in {
"local_neighbor_path_competition",
Expand Down Expand Up @@ -509,6 +515,39 @@ def _local_recurrent(
)


def _without_zero_hop_anchor(
result: PropagationResult,
seed_ids: list[str],
entry: dict[str, float],
settings: DynamicsSettings,
) -> PropagationResult:
"""Remove the decayed zero-hop seed residual from an anchored graph signal."""
residual_factor = settings.recurrent_decay ** result.diagnostics.steps
activation = dict(result.activation)
for seed_id in seed_ids:
remaining = activation.get(seed_id, 0.0) - entry[seed_id] * residual_factor
if remaining > 1e-15:
activation[seed_id] = remaining
else:
activation.pop(seed_id, None)
paths = {
node_id: [path for path in node_paths if path.steps]
for node_id, node_paths in result.paths.items()
}
paths = {node_id: node_paths for node_id, node_paths in paths.items() if node_paths}
diagnostics = PropagationDiagnostics(
strategy=result.diagnostics.strategy,
steps=result.diagnostics.steps,
expansions=result.diagnostics.expansions,
activation_total=sum(activation.values()),
converged=result.diagnostics.converged,
stop_reason=result.diagnostics.stop_reason,
active_path_count=sum(len(node_paths) for node_paths in paths.values()),
competition_sets=result.diagnostics.competition_sets,
)
return PropagationResult(activation, paths, diagnostics)


def _local_path_recurrent(
*,
query: str,
Expand Down
97 changes: 72 additions & 25 deletions src/neuron_graph_rag/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ class EngineConfig:
recurrent_decay: float = 0.5
convergence_tolerance: float = 1e-9
max_active_paths_per_node: int = 4
use_dense_retrieval: bool = True
use_graph_propagation: bool = True

def __post_init__(self) -> None:
for name in (
Expand Down Expand Up @@ -78,6 +80,8 @@ def __post_init__(self) -> None:
"local_neighbor_query_competition",
"local_neighbor_path_competition",
"local_neighbor_query_path_competition",
"anchored_local_competition",
"anchored_local_query_competition",
}:
raise ValueError("Unknown activation_strategy")
if self.activation_budget <= 0.0:
Expand All @@ -98,6 +102,10 @@ def __post_init__(self) -> None:
raise ValueError("convergence_tolerance must be non-negative")
if self.max_active_paths_per_node < 1:
raise ValueError("max_active_paths_per_node must be positive")
if not self.use_dense_retrieval and self.dense_weight != 0.0:
raise ValueError("dense_weight must be zero when dense retrieval is disabled")
if not self.use_graph_propagation and self.graph_weight != 0.0:
raise ValueError("graph_weight must be zero when graph propagation is disabled")


class NeuronGraphRAG:
Expand Down Expand Up @@ -176,7 +184,11 @@ def search(
timestamp = self._timestamp(now)

sparse_raw = self.sparse_retriever.score(query, nodes)
dense_raw = self.dense_retriever.score(query, nodes)
dense_raw = (
self.dense_retriever.score(query, nodes)
if self.config.use_dense_retrieval
else {node.node_id: 0.0 for node in nodes}
)
sparse = normalize_scores(sparse_raw)
dense = normalize_scores(dense_raw)
entry = {
Expand All @@ -195,29 +207,43 @@ def search(
)[: self.config.seed_count]
if score > 0.0
]
propagation = propagate(
query=query,
seed_ids=seed_ids,
entry=entry,
nodes={node.node_id: node for node in nodes},
outgoing_edges=self.store.outgoing_edges,
settings=DynamicsSettings(
strategy=self.config.activation_strategy,
max_hops=self.config.max_hops,
hop_decay=self.config.hop_decay,
max_expansions=self.config.max_propagation_expansions,
activation_budget=self.config.activation_budget,
inhibition_ratio=self.config.inhibition_ratio,
inhibition_top_k=self.config.inhibition_top_k,
query_transmission_floor=self.config.query_transmission_floor,
query_transmission_power=self.config.query_transmission_power,
recurrent_steps=self.config.recurrent_steps,
recurrent_decay=self.config.recurrent_decay,
convergence_tolerance=self.config.convergence_tolerance,
max_active_paths_per_node=self.config.max_active_paths_per_node,
),
)
graph_activation, paths = propagation.activation, propagation.paths
if self.config.use_graph_propagation:
propagation = propagate(
query=query,
seed_ids=seed_ids,
entry=entry,
nodes={node.node_id: node for node in nodes},
outgoing_edges=self.store.outgoing_edges,
settings=DynamicsSettings(
strategy=self.config.activation_strategy,
max_hops=self.config.max_hops,
hop_decay=self.config.hop_decay,
max_expansions=self.config.max_propagation_expansions,
activation_budget=self.config.activation_budget,
inhibition_ratio=self.config.inhibition_ratio,
inhibition_top_k=self.config.inhibition_top_k,
query_transmission_floor=self.config.query_transmission_floor,
query_transmission_power=self.config.query_transmission_power,
recurrent_steps=self.config.recurrent_steps,
recurrent_decay=self.config.recurrent_decay,
convergence_tolerance=self.config.convergence_tolerance,
max_active_paths_per_node=self.config.max_active_paths_per_node,
),
)
graph_activation, paths = propagation.activation, propagation.paths
propagation_diagnostics = propagation.diagnostics.as_dict()
else:
graph_activation, paths = {}, {}
propagation_diagnostics = {
"strategy": "graph_disabled",
"steps": 0,
"expansions": 0,
"activation_total": 0.0,
"converged": True,
"stop_reason": "graph_disabled",
"active_path_count": 0,
"competition_sets": [],
}
normalized_activation = normalize_scores(
{node.node_id: graph_activation.get(node.node_id, 0.0) for node in nodes}
)
Expand All @@ -242,6 +268,11 @@ def search(
key=lambda path: (-path.contribution, path.seed_id),
)[: self.config.max_paths_per_node]
),
sparse_raw_score=sparse_raw[node.node_id],
dense_raw_score=dense_raw[node.node_id],
normalized_graph_activation=normalized_activation[node.node_id],
entry_anchor_before_competition=entry[node.node_id],
entry_anchor_after_competition=entry[node.node_id],
)
for node in nodes
]
Expand All @@ -266,12 +297,28 @@ def search(
for rank, hit in enumerate(selected_hits, start=1)
),
)
propagation_diagnostics.update(
{
"use_dense_retrieval": self.config.use_dense_retrieval,
"use_graph_propagation": self.config.use_graph_propagation,
"entry_anchor_invariant": all(
hit.entry_anchor_before_competition
== hit.entry_anchor_after_competition
for hit in hits
),
"graph_signal_excludes_zero_hop": all(
path.steps
for node_paths in paths.values()
for path in node_paths
),
}
)
return SearchTrace(
trace_id,
query,
timestamp,
selected_hits,
propagation.diagnostics.as_dict(),
propagation_diagnostics,
)

def record_success(
Expand Down
Loading