From 42438f35a1080fcd301d4a8826bd0c3d6ebc5a36 Mon Sep 17 00:00:00 2001
From: Takashi Yamashina
Date: Wed, 5 Aug 2026 12:59:33 +0900
Subject: [PATCH 1/2] paths: walk every simple path between two concepts
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
POST /contexts/{name}/paths (#418) — the 手繰り itself: activate
spreads outward and explore sweeps a neighborhood, but neither answers
"how are these two related?" without the client re-walking the graph.
Each trail carries the whole concept path plus its associations in
walk order with full attributions.
Traversal follows explore's exact discipline (bidirectional, labels
never bridge, retracted edges never bridge, ADR 0009 §6.3's
schema:type exclusion via the same monomorphized-visible-closure
pattern). Ranking is deterministic: distance ascending, then
weakest-link strength descending on raw cumulative |sum| — the same
corroboration-outranks-emphasis discipline activate documents — then
insertion order. Simple-path enumeration is combinatorial in the worst
case, so one call examines at most a fixed edge budget and reports
capped: true honestly instead of a silently complete-looking total.
Exposed as the paths MCP tool, in both core SDKs per
sdk/spec/surface.yaml, pinned as a wire-contract fixture (additive,
HTTP_CONTRACT unchanged), classified Role::Read, counted as
taguru_searches_total{op="paths"}, and documented in the /protocol
manual, README, and docs site.
Closes #418
Claude-Session: https://claude.ai/code/session_014RfogjbkTt5f14rz8fzYgP
---
CHANGELOG.md | 27 ++
README.md | 4 +-
docs/concepts.html | 6 +-
docs/modeling.html | 3 +-
sdk/python/src/taguru/__init__.py | 4 +
sdk/python/src/taguru/_async/client.py | 26 +
sdk/python/src/taguru/_models.py | 23 +
sdk/python/src/taguru/_sync/client.py | 26 +
.../tests/integration/test_full_loop.py | 6 +
sdk/python/tests/unit/test_wire_contract.py | 2 +
sdk/python/uv.lock | 2 +-
sdk/spec/surface.yaml | 4 +
sdk/typescript/src/client.ts | 24 +
sdk/typescript/src/index.ts | 2 +
sdk/typescript/src/models.ts | 23 +
.../tests/integration/client.test.ts | 6 +
.../tests/unit/wire-contract.test.ts | 1 +
src/api.rs | 43 +-
src/api/explore.rs | 88 +++-
src/auth.rs | 1 +
src/context.rs | 43 ++
src/context/traverse.rs | 458 +++++++++++++++++-
src/llm-protocol.md | 8 +-
src/main.rs | 1 +
src/mcp.rs | 6 +-
src/mcp/route.rs | 13 +
src/mcp/schema.rs | 14 +
src/metrics/taxonomy.rs | 5 +-
tests/fixtures/wire/http/paths.json | 55 +++
tests/fixtures/wire/shapes.json | 1 +
tests/http_api/contract.rs | 16 +
tests/http_api/mcp_basics.rs | 48 ++
tests/http_api/retrieval_core.rs | 22 +
tests/http_api/schema_type_label.rs | 38 ++
34 files changed, 1033 insertions(+), 16 deletions(-)
create mode 100644 tests/fixtures/wire/http/paths.json
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5ea14b5d..7955d185 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,33 @@ Entries that change an on-disk format or a response shape say so.
## [Unreleased]
+### Added
+- `POST /contexts/{name}/paths` (#418) — the 手繰り between two
+ concepts: every simple path from an origin to a target, shortest
+ first, each trail carrying the whole concept `path` plus its
+ associations in walk order with full attributions. `activate`
+ spreads outward and `explore` sweeps a neighborhood; neither could
+ answer "how are these two related?" without the client re-walking
+ the graph by hand. Traversal follows `explore`'s exact discipline —
+ bidirectional, labels never bridge, retracted edges never bridge,
+ ADR 0009 §6.3's `schema:type` exclusion applies once a schema
+ document exists — and ranking is deterministic: distance ascending,
+ then weakest-link strength descending (the smallest raw cumulative
+ |sum| along the trail — corroboration outranks a single emphatic
+ assertion, the same discipline `activate` ranks by), then insertion
+ order. Simple-path enumeration is combinatorial in the worst case,
+ so one call examines at most a fixed edge budget and reports
+ `capped: true` when it bites — `total` is then a lower bound, never
+ a silently complete-looking count. `max_depth` shares explore's
+ ceiling (10); `limit` defaults to 10, capped at 100 (each trail is a
+ whole chain of associations, so pages weigh more than single-match
+ endpoints). Exposed as the `paths` MCP tool, in both core SDKs
+ (`paths`/`paths()`, `sdk/spec/surface.yaml` like every other
+ cross-language method), pinned as a wire-contract fixture
+ (additive: `HTTP_CONTRACT` unchanged), counted on `/metrics` as
+ `taguru_searches_total{op="paths"}`, and documented in the
+ `/protocol` manual's endpoint table and retrieval discipline.
+
## [0.7.0] - 2026-08-05
### Added
diff --git a/README.md b/README.md
index 7fc54b4c..b97ca38a 100644
--- a/README.md
+++ b/README.md
@@ -71,7 +71,9 @@ search across several contexts at once, every match tagged with the
context it came from. Every search response also carries a `plan`:
which contexts were actually searched and — for passage search — which
lanes ran there and why not when one was skipped, with the effective
-cosine floor. Deep dives (`activate`, `explore`) stay per-context:
+cosine floor. Deep dives (`activate`, `explore`, and `paths` — every
+simple path between two concepts, shortest first, each hop carrying
+its citations, for "how are these two related?") stay per-context:
search across, then pull the thread where it answered. How to draw
those boundaries for real documents — which parts of a paper or a
codebase become contexts, and which become groups — is the [modeling
diff --git a/docs/concepts.html b/docs/concepts.html
index b43a6968..1abf3c9b 100644
--- a/docs/concepts.html
+++ b/docs/concepts.html
@@ -150,8 +150,8 @@ Groups bundle contexts — and searches cross them
context it came from. Graph matches merge on |weight| (weights share one
scale — evidence mass); passage hits interleave by per-context rank, because passage
scores are corpus-local. Deep dives (activate, explore,
- resolve) stay per-context by design: search across, then pull the thread
- inside the context that answered.
+ paths, resolve) stay per-context by design: search across, then
+ pull the thread inside the context that answered.
Worked mappings — a paper split into section contexts and chapter groups, parts as
@@ -185,7 +185,7 @@
The retrieval loop
01Selectpick the context with list_contexts
02Resolveresolve / resolve_label turn the cue into canonical names
03Surveydescribe → query to narrow down
- 04Expandactivate / explore gather related knowledge
+ 04Expandactivate / explore gather related knowledge; paths threads two concepts together
05Verifycite_passage checks the original text
06Cast the netsearch_passages when the graph can't reach
diff --git a/docs/modeling.html b/docs/modeling.html
index 7e1f3d8e..cfda9b7d 100644
--- a/docs/modeling.html
+++ b/docs/modeling.html
@@ -232,7 +232,8 @@ Searching across papers names groups, not a super-context
The response's plan names exactly which contexts the two groups expanded to, so
"which paper answered" is never a guess. Deep dives — activate,
- explore, resolve, and the composite retrieve tool —
+ explore, paths, resolve, and the composite
+ retrieve tool —
stay per-context by design (see Concepts): search
across with a cross call, then pull the thread inside whichever context answered. Reaching
for retrieve against one context after deliberately splitting a corpus into
diff --git a/sdk/python/src/taguru/__init__.py b/sdk/python/src/taguru/__init__.py
index 6acb3c8c..17b898e6 100644
--- a/sdk/python/src/taguru/__init__.py
+++ b/sdk/python/src/taguru/__init__.py
@@ -100,6 +100,7 @@
PassageLanes,
PassageLookup,
PassagePage,
+ PathsPage,
RankingExplain,
Recollection,
RefreshBreakdown,
@@ -126,6 +127,7 @@
StoredPassages,
TermContribution,
TieredResolution,
+ Trail,
TwinPair,
TypeDef,
UnsourcedEdge,
@@ -257,6 +259,7 @@
"PassageLanes",
"PassageLookup",
"PassagePage",
+ "PathsPage",
"RankingExplain",
"Recollection",
"RefreshBreakdown",
@@ -283,6 +286,7 @@
"StoredPassages",
"TermContribution",
"TieredResolution",
+ "Trail",
"TwinPair",
"TypeDef",
"UnsourcedEdge",
diff --git a/sdk/python/src/taguru/_async/client.py b/sdk/python/src/taguru/_async/client.py
index 8cd5cacd..c19593cd 100644
--- a/sdk/python/src/taguru/_async/client.py
+++ b/sdk/python/src/taguru/_async/client.py
@@ -55,6 +55,7 @@
PassageHit,
PassageLookup,
PassagePage,
+ PathsPage,
RefreshOutcome,
ResolveExplanation,
RetractAssociationOutcome,
@@ -928,6 +929,31 @@ async def explore(
result = await self._post("/explore", body)
return decode(ExplorePage, result) # type: ignore[no-any-return]
+ async def paths(
+ self,
+ origins: str | Sequence[str],
+ targets: str | Sequence[str],
+ *,
+ max_depth: int | None = None,
+ limit: int | None = None,
+ ) -> PathsPage:
+ """Every simple path from an origin to a target, shortest first.
+
+ Each trail carries the whole concept ``path`` plus its
+ associations in walk order; ``capped`` means enumeration hit the
+ server's budget, so ``total`` is a lower bound.
+ """
+ body = drop_none(
+ {
+ "origins": [origins] if isinstance(origins, str) else list(origins),
+ "targets": [targets] if isinstance(targets, str) else list(targets),
+ "max_depth": max_depth,
+ "limit": limit,
+ }
+ )
+ result = await self._post("/paths", body)
+ return decode(PathsPage, result) # type: ignore[no-any-return]
+
async def activate(
self,
origins: str | Sequence[str],
diff --git a/sdk/python/src/taguru/_models.py b/sdk/python/src/taguru/_models.py
index ad2580b3..f478e0f4 100644
--- a/sdk/python/src/taguru/_models.py
+++ b/sdk/python/src/taguru/_models.py
@@ -286,6 +286,29 @@ class ActivationPage:
matches: list[Activation]
+@dataclass(slots=True, frozen=True)
+class Trail:
+ """One path from an origin to a target: the concept trail plus every
+ association walked, in order. ``strength`` is the weakest link
+ (smallest raw cumulative ``|sum|`` along the trail) — an ordering
+ within one call, never comparable across calls."""
+
+ distance: int
+ path: list[str]
+ strength: float
+ associations: list[Association] = field(default_factory=list)
+
+
+@dataclass(slots=True, frozen=True)
+class PathsPage:
+ """Trails, shortest first. ``capped`` means enumeration hit the
+ server's budget, so ``total`` is a lower bound."""
+
+ total: int
+ capped: bool
+ matches: list[Trail]
+
+
@dataclass(slots=True, frozen=True)
class TieredResolution:
"""One resolve candidate. ``tier`` is ``"lexical"`` or ``"semantic"``.
diff --git a/sdk/python/src/taguru/_sync/client.py b/sdk/python/src/taguru/_sync/client.py
index 2dd40d37..96415a55 100644
--- a/sdk/python/src/taguru/_sync/client.py
+++ b/sdk/python/src/taguru/_sync/client.py
@@ -49,6 +49,7 @@
PassageHit,
PassageLookup,
PassagePage,
+ PathsPage,
RefreshOutcome,
ResolveExplanation,
RetractAssociationOutcome,
@@ -916,6 +917,31 @@ def explore(
result = self._post("/explore", body)
return decode(ExplorePage, result) # type: ignore[no-any-return]
+ def paths(
+ self,
+ origins: str | Sequence[str],
+ targets: str | Sequence[str],
+ *,
+ max_depth: int | None = None,
+ limit: int | None = None,
+ ) -> PathsPage:
+ """Every simple path from an origin to a target, shortest first.
+
+ Each trail carries the whole concept ``path`` plus its
+ associations in walk order; ``capped`` means enumeration hit the
+ server's budget, so ``total`` is a lower bound.
+ """
+ body = drop_none(
+ {
+ "origins": [origins] if isinstance(origins, str) else list(origins),
+ "targets": [targets] if isinstance(targets, str) else list(targets),
+ "max_depth": max_depth,
+ "limit": limit,
+ }
+ )
+ result = self._post("/paths", body)
+ return decode(PathsPage, result) # type: ignore[no-any-return]
+
def activate(
self,
origins: str | Sequence[str],
diff --git a/sdk/python/tests/integration/test_full_loop.py b/sdk/python/tests/integration/test_full_loop.py
index 3c41a1a3..48ed35f1 100644
--- a/sdk/python/tests/integration/test_full_loop.py
+++ b/sdk/python/tests/integration/test_full_loop.py
@@ -159,6 +159,12 @@ def test_graph_reads(client: Taguru, fresh_name: str) -> None:
strengths = [a.strength for a in activated.matches]
assert strengths == sorted(strengths, reverse=True)
+ threads = ctx.paths("青嶺酒造", "寒仕込み")
+ assert threads.total == 1
+ assert not threads.capped
+ assert threads.matches[0].path == ["青嶺酒造", "高瀬", "寒仕込み"]
+ assert [a.label for a in threads.matches[0].associations] == ["杜氏", "重視する"]
+
audit = ctx.unreachable_from(["青嶺酒造"])
assert audit.total == 0
diff --git a/sdk/python/tests/unit/test_wire_contract.py b/sdk/python/tests/unit/test_wire_contract.py
index b36a366e..a811f3b3 100644
--- a/sdk/python/tests/unit/test_wire_contract.py
+++ b/sdk/python/tests/unit/test_wire_contract.py
@@ -39,6 +39,7 @@
ExplorePage,
MatchPage,
PassagePage,
+ PathsPage,
)
# sdk/python/tests/unit/test_wire_contract.py -> repo root: same depth
@@ -74,6 +75,7 @@ def _load_fixtures() -> list[tuple[Path, dict[str, Any]]]:
"sources_search": PassagePage,
"explore": ExplorePage,
"activate": ActivationPage,
+ "paths": PathsPage,
"communities_search": CommunityPage,
"evidence_mixed_lanes": EvidencePackage,
"evidence_budget_constrained": EvidencePackage,
diff --git a/sdk/python/uv.lock b/sdk/python/uv.lock
index e1bcfa82..6729550f 100644
--- a/sdk/python/uv.lock
+++ b/sdk/python/uv.lock
@@ -876,7 +876,7 @@ wheels = [
[[package]]
name = "taguru"
-version = "0.6.0"
+version = "0.7.0"
source = { editable = "." }
dependencies = [
{ name = "httpx" },
diff --git a/sdk/spec/surface.yaml b/sdk/spec/surface.yaml
index 1cfb61a2..ee9e4cb5 100644
--- a/sdk/spec/surface.yaml
+++ b/sdk/spec/surface.yaml
@@ -101,6 +101,10 @@ classes:
route: "POST /contexts/{name}/explore"
args: [origins]
options: [max_depth, limit, after]
+ paths:
+ route: "POST /contexts/{name}/paths"
+ args: [origins, targets]
+ options: [max_depth, limit]
unreachable_from:
route: "POST /contexts/{name}/unreachable_from"
args: [origins]
diff --git a/sdk/typescript/src/client.ts b/sdk/typescript/src/client.ts
index 8dd067dd..66955310 100644
--- a/sdk/typescript/src/client.ts
+++ b/sdk/typescript/src/client.ts
@@ -43,6 +43,7 @@ import type {
PassageHit,
PassageLookup,
PassagePage,
+ PathsPage,
QuestionSpec,
RefreshOutcome,
RerankRequest,
@@ -992,6 +993,29 @@ export class Context {
return result as ExplorePage;
}
+ /**
+ * Every simple path from an origin to a target, shortest first. Each trail
+ * carries the whole concept `path` plus its associations in walk order;
+ * `capped` means enumeration hit the server's budget, so `total` is a
+ * lower bound.
+ */
+ async paths(
+ origins: string | string[],
+ targets: string | string[],
+ options: { max_depth?: number; limit?: number } = {},
+ ): Promise {
+ const result = await this.post(
+ "/paths",
+ dropUndefined({
+ origins: typeof origins === "string" ? [origins] : origins,
+ targets: typeof targets === "string" ? [targets] : targets,
+ max_depth: options.max_depth,
+ limit: options.limit,
+ }),
+ );
+ return result as PathsPage;
+ }
+
/** Spreading activation from origins, strongest first. */
async activate(
origins: string | string[],
diff --git a/sdk/typescript/src/index.ts b/sdk/typescript/src/index.ts
index f29e1b2f..9cba5875 100644
--- a/sdk/typescript/src/index.ts
+++ b/sdk/typescript/src/index.ts
@@ -110,6 +110,7 @@ export {
type PassageLanes,
type PassageLookup,
type PassagePage,
+ type PathsPage,
type QuestionSpec,
type RankingExplain,
type Recollection,
@@ -139,6 +140,7 @@ export {
type StoredPassages,
type TermContribution,
type TieredResolution,
+ type Trail,
type TwinPair,
type TypeDef,
type UnsourcedEdge,
diff --git a/sdk/typescript/src/models.ts b/sdk/typescript/src/models.ts
index eb0a3646..f26f14a9 100644
--- a/sdk/typescript/src/models.ts
+++ b/sdk/typescript/src/models.ts
@@ -336,6 +336,29 @@ export interface ActivationPage {
matches: Activation[];
}
+/**
+ * One path from an origin to a target: the concept trail plus every
+ * association walked, in order. `strength` is the weakest link (smallest raw
+ * cumulative |sum| along the trail) — an ordering within one call, never
+ * comparable across calls.
+ */
+export interface Trail {
+ distance: number;
+ path: string[];
+ strength: number;
+ associations: Association[];
+}
+
+/**
+ * Trails, shortest first. `capped` means enumeration hit the server's
+ * budget, so `total` is a lower bound.
+ */
+export interface PathsPage {
+ total: number;
+ capped: boolean;
+ matches: Trail[];
+}
+
/**
* One resolve candidate. `kind` (lexical tier only) is
* "exact"/"alias"/"containment"/"fuzzy" — never adopt a containment/fuzzy hit
diff --git a/sdk/typescript/tests/integration/client.test.ts b/sdk/typescript/tests/integration/client.test.ts
index 1a760ffb..8b12e8b8 100644
--- a/sdk/typescript/tests/integration/client.test.ts
+++ b/sdk/typescript/tests/integration/client.test.ts
@@ -156,6 +156,12 @@ describe("graph writes and reads", () => {
const strengths = activated.matches.map((m) => m.strength);
expect(strengths).toEqual([...strengths].sort((a, b) => b - a));
+ const threads = await ctx.paths("青嶺酒造", "寒仕込み");
+ expect(threads.total).toBe(1);
+ expect(threads.capped).toBe(false);
+ expect(threads.matches[0]!.path).toEqual(["青嶺酒造", "高瀬", "寒仕込み"]);
+ expect(threads.matches[0]!.associations.map((a) => a.label)).toEqual(["杜氏", "重視する"]);
+
expect((await ctx.unreachableFrom(["青嶺酒造"])).total).toBe(0);
const labels = await ctx.listLabels();
diff --git a/sdk/typescript/tests/unit/wire-contract.test.ts b/sdk/typescript/tests/unit/wire-contract.test.ts
index 8295951f..ae61e4c6 100644
--- a/sdk/typescript/tests/unit/wire-contract.test.ts
+++ b/sdk/typescript/tests/unit/wire-contract.test.ts
@@ -106,6 +106,7 @@ const TYPED_OPERATIONS = [
"sources_search",
"explore",
"activate",
+ "paths",
"communities_search",
"evidence_mixed_lanes",
"evidence_budget_constrained",
diff --git a/src/api.rs b/src/api.rs
index b8b56ecb..2d8761d2 100644
--- a/src/api.rs
+++ b/src/api.rs
@@ -13,7 +13,7 @@ use axum::extract::{FromRequest, Request};
use axum::http::{Method, StatusCode, Uri};
use axum::response::{IntoResponse, Response};
use serde::{Deserialize, Serialize};
-use taguru::context::{Activation, Association, Attribution, Recollection};
+use taguru::context::{Activation, Association, Attribution, Recollection, Trail};
use crate::groups::{MAX_GROUP_DEPTH, MAX_GROUP_MEMBERS, NestingViolation};
use crate::metrics::ErrorKind;
@@ -57,7 +57,7 @@ pub use contexts::{
};
pub use coverage::{embeddings_status, labels, refresh_embeddings, unreachable_from};
pub use evidence::assemble::assemble_evidence;
-pub use explore::{activate, describe, explore};
+pub use explore::{activate, describe, explore, paths};
pub use groups::{create_group, delete_group, get_group, list_groups, rename_group, update_group};
pub use import::{
GroupImportOutcome, ImportOutcome, ImportStreamOutcome, SchemaImportOutcome, compact_context,
@@ -1233,6 +1233,12 @@ pub(crate) const MAX_MATCH_LIMIT: usize = 1000;
/// unreachable_from.
const MAX_EXPLORE_DEPTH: usize = 10;
+/// Result cap for paths, tighter than [`MAX_MATCH_LIMIT`]: each trail
+/// is a whole chain of associations with full attributions — up to
+/// [`MAX_EXPLORE_DEPTH`] of them — so a page of trails weighs an order
+/// of magnitude more than a page of single matches.
+const MAX_PATHS_LIMIT: usize = 100;
+
/// Per-request association batch cap — one document's facts arrive as
/// one request; anything past this is asked to split. (`pub(crate)`:
/// the offline import chunks its applies at the same size.)
@@ -1860,6 +1866,39 @@ fn cross_associations_out(
.collect()
}
+/// `Trail`'s wire shape: identical, with each association reshaped to
+/// carry resolved section/locator markers (see [`AssociationOut`]).
+#[derive(Serialize)]
+pub struct TrailOut {
+ pub distance: usize,
+ pub path: Vec,
+ pub strength: f64,
+ pub associations: Vec,
+}
+
+/// Same as [`associations_out`], for trails (paths' results) — one
+/// `resolve_markers` call across every association of every trail on
+/// the page, not one per trail.
+fn trails_out(state: &AppState, name: &str, matches: Vec) -> Vec {
+ let markers = state.resolve_markers(
+ name,
+ locator_keys(matches.iter().flat_map(|trail| trail.associations.iter())),
+ );
+ matches
+ .into_iter()
+ .map(|trail| TrailOut {
+ distance: trail.distance,
+ path: trail.path,
+ strength: trail.strength,
+ associations: trail
+ .associations
+ .into_iter()
+ .map(|association| association_out(association, &markers))
+ .collect(),
+ })
+ .collect()
+}
+
/// Same as [`associations_out`], for activations (activate's results).
fn activations_out(state: &AppState, name: &str, matches: Vec) -> Vec {
let markers = state.resolve_markers(
diff --git a/src/api/explore.rs b/src/api/explore.rs
index 107feda6..ca2aee7a 100644
--- a/src/api/explore.rs
+++ b/src/api/explore.rs
@@ -13,8 +13,9 @@ use crate::registry::AppState;
use super::{
ActivationOut, AppJson, AppPath, ExploreCursor, MAX_EXPLORE_DEPTH, MAX_MATCH_LIMIT,
- RecollectionOut, access_error, activations_out, clamp, deadline_exceeded, explore_page, ok,
- overlong, recollections_out, search_log_enabled,
+ MAX_PATHS_LIMIT, RecollectionOut, TrailOut, access_error, activations_out, clamp,
+ deadline_exceeded, explore_page, ok, overlong, recollections_out, search_log_enabled,
+ trails_out,
};
#[derive(Debug, Deserialize)]
@@ -132,6 +133,89 @@ pub async fn explore(
}
}
+#[derive(Debug, Deserialize)]
+pub struct PathsRequest {
+ pub origins: Vec,
+ pub targets: Vec,
+ /// Hop ceiling per trail. Omitted — and everything above it — means
+ /// the server maximum ([`MAX_EXPLORE_DEPTH`]), the same ceiling
+ /// explore applies.
+ pub max_depth: Option,
+ /// Trail cap. Omitted means 10, ceiling [`MAX_PATHS_LIMIT`] — each
+ /// trail is a whole chain of associations, so the page is bounded
+ /// tighter than single-match endpoints.
+ pub limit: Option,
+}
+
+/// A bounded paths result: `{total, matches}` like [`ExplorePage`],
+/// plus `capped` — `Context::paths` bounds its own enumeration, and
+/// `capped == true` says that budget bit, so `total` is a lower bound
+/// rather than a count of every trail that exists.
+#[derive(Serialize)]
+pub struct PathsPage {
+ pub total: usize,
+ pub capped: bool,
+ pub matches: Vec,
+}
+
+pub async fn paths(
+ State(state): State,
+ AppPath(name): AppPath,
+ axum::Extension(deadline): axum::Extension,
+ AppJson(request): AppJson,
+) -> Response {
+ let started_at = Instant::now();
+ if let Some(refusal) = overlong("origins", request.origins.len(), started_at) {
+ return refusal;
+ }
+ if let Some(refusal) = overlong("targets", request.targets.len(), started_at) {
+ return refusal;
+ }
+ if deadline.expired() {
+ return deadline_exceeded(started_at);
+ }
+ // ADR 0009 §6.3 exclusion 1: `schema:type` never bridges a walk —
+ // same reason and same `block_in_place` rationale as `explore`.
+ let hidden = tokio::task::block_in_place(|| state.hidden_label(&name));
+ let excluded: Vec<&str> = hidden.into_iter().collect();
+ match state.read_context(&name, |context| {
+ let origins: Vec<&str> = request.origins.iter().map(String::as_str).collect();
+ let targets: Vec<&str> = request.targets.iter().map(String::as_str).collect();
+ context.paths_excluding(
+ &origins,
+ &targets,
+ clamp(request.max_depth, Context::UNBOUNDED, MAX_EXPLORE_DEPTH),
+ clamp(request.limit, 10, MAX_PATHS_LIMIT),
+ &excluded,
+ )
+ }) {
+ Ok(result) => {
+ state.note_search(SearchOp::Paths, &name, result.total == 0);
+ if search_log_enabled() {
+ tracing::info!(
+ target: "taguru::search",
+ context = %name,
+ op = "paths",
+ origins = %request.origins.join(","),
+ targets = %request.targets.join(","),
+ hits = result.total,
+ "search",
+ );
+ }
+ let matches = trails_out(&state, &name, result.trails);
+ ok(
+ PathsPage {
+ total: result.total,
+ capped: result.capped,
+ matches,
+ },
+ started_at,
+ )
+ }
+ Err(failure) => access_error(&state, failure, &name, started_at),
+ }
+}
+
/// A bounded activation result: the same `{total, matches}` shape as
/// [`MatchPage`], but `total` comes straight from `Context::activate`,
/// which already sorts and truncates internally rather than going
diff --git a/src/auth.rs b/src/auth.rs
index 94e3b562..8b799ea5 100644
--- a/src/auth.rs
+++ b/src/auth.rs
@@ -794,6 +794,7 @@ pub(crate) fn required_role(method: &Method, route: &str) -> Role {
| (&Method::POST, "/contexts/{name}/describe")
| (&Method::POST, "/contexts/{name}/explore")
| (&Method::POST, "/contexts/{name}/activate")
+ | (&Method::POST, "/contexts/{name}/paths")
| (&Method::POST, "/contexts/{name}/resolve")
| (&Method::POST, "/contexts/{name}/resolve/explain")
| (&Method::POST, "/contexts/{name}/resolve_label")
diff --git a/src/context.rs b/src/context.rs
index b0c84f7c..b20bc7e0 100644
--- a/src/context.rs
+++ b/src/context.rs
@@ -290,6 +290,49 @@ pub struct Activation {
pub association: Association,
}
+/// One path returned by [`Context::paths`]: a concrete chain of
+/// associations connecting an origin concept to a target concept — the
+/// thread itself, pulled end to end, with every fact along it carrying
+/// its full citation data.
+#[derive(Debug, Clone, PartialEq, Serialize)]
+pub struct Trail {
+ /// How many associations the trail walks — `path.len() - 1`, never 0:
+ /// zero hops of association-following connects nothing, so a concept
+ /// is never "connected to itself" by an empty trail.
+ pub distance: usize,
+ /// Concept names from the origin to the target, origin first — the
+ /// same connective tissue [`Recollection::path`] carries, but here it
+ /// spans the whole trail rather than stopping at the reached endpoint.
+ pub path: Vec,
+ /// The weakest link: the smallest raw cumulative |sum| among the
+ /// associations walked. A chain of knowledge is only as reliable as
+ /// its least-supported hop, so trails of equal length rank by this,
+ /// descending. Like [`Activation::strength`] it ranks on the raw
+ /// cumulative total — NOT the averaged [`Association::weight`] — so
+ /// corroboration keeps outranking a single assertion of the same
+ /// average intensity; magnitude ranks, sign is content. Ordinal:
+ /// compare within one call's results, not across calls or corpus
+ /// versions.
+ pub strength: f64,
+ /// The associations walked, in trail order. Each step's endpoints
+ /// appear in `path` at the same index and the next one — though the
+ /// association's own `subject`/`object` may sit in either order,
+ /// since traversal follows meaning-directional edges both ways.
+ pub associations: Vec,
+}
+
+/// What [`Context::paths`] returns: the pre-truncation trail count (so a
+/// caller can tell a complete result from a truncated one, mirroring
+/// [`Context::activate`]), whether enumeration hit the server's expansion
+/// budget — `capped == true` means `total` is a lower bound, not a count
+/// of everything that exists — and the `limit` best trails.
+#[derive(Debug, Clone, PartialEq, Serialize)]
+pub struct PathsResult {
+ pub total: usize,
+ pub capped: bool,
+ pub trails: Vec,
+}
+
/// One candidate name produced by [`Context::resolve`] (concept names) or
/// [`Context::resolve_label`] (relation labels), scored by how much of the
/// longer string the lexical overlap covers (1.0 = exact match).
diff --git a/src/context/traverse.rs b/src/context/traverse.rs
index dfb5db87..8d6f897d 100644
--- a/src/context/traverse.rs
+++ b/src/context/traverse.rs
@@ -5,8 +5,8 @@ use std::collections::{BinaryHeap, HashMap, HashSet, VecDeque};
use crate::deadline::{Deadline, DeadlineExceeded};
use super::{
- Activation, Association, ConceptId, Context, EdgeId, EdgeRecord, LabelId, Recollection,
- accumulate_saturating, clamp_unit_or,
+ Activation, Association, ConceptId, Context, EdgeId, EdgeRecord, LabelId, PathsResult,
+ Recollection, Trail, accumulate_saturating, clamp_unit_or,
};
impl Context {
@@ -397,6 +397,261 @@ impl Context {
(total, matches)
}
+ /// Edge-expansion budget for one [`Context::paths`] call. Simple-path
+ /// enumeration is combinatorial in the worst case (a dense clique
+ /// holds factorially many simple paths between any two members), so
+ /// unlike `explore` — whose work is bounded by the component's edge
+ /// count — `paths` needs an explicit ceiling on how many edges the
+ /// forward walk may examine. 100k expansions is far beyond any trail
+ /// set a caller could digest while keeping the worst case bounded;
+ /// hitting it is reported honestly as `capped` rather than silently
+ /// returning a result that looks exhaustive.
+ const PATHS_EXPANSION_BUDGET: usize = 100_000;
+
+ /// Walks the network from `origins` to `targets` and returns up to
+ /// `limit` simple paths connecting them — the 手繰り itself: not what
+ /// lies near one concept, but through which concrete chain of
+ /// associations two concepts relate, each hop carrying its full
+ /// citation data.
+ ///
+ /// Traversal is structural, with exactly [`Context::explore`]'s
+ /// discipline: edges are followed in both directions (an association
+ /// is directional in meaning, not in reachability), shared relation
+ /// labels never bridge, retracted edges never bridge, and origins or
+ /// targets naming unknown concepts contribute nothing. Only simple
+ /// paths are enumerated — no concept repeats within one trail — so a
+ /// concept that is both origin and target yields no trail to itself
+ /// (`distance` is never 0), while a trail may legitimately pass
+ /// through one target on its way to another, and both prefixes are
+ /// reported.
+ ///
+ /// Ranking is deterministic: distance ascending (the shortest thread
+ /// is the strongest claim of relatedness), then weakest-link strength
+ /// descending — see [`Trail::strength`] — then insertion order, the
+ /// same final tie-break `explore` uses. `max_depth` bounds the hops
+ /// per trail and `limit` bounds the trails returned; the
+ /// pre-truncation count comes back as [`PathsResult::total`].
+ /// Enumeration that would exceed [`Context::PATHS_EXPANSION_BUDGET`]
+ /// stops early and says so via [`PathsResult::capped`].
+ pub fn paths(
+ &self,
+ origins: &[&str],
+ targets: &[&str],
+ max_depth: usize,
+ limit: usize,
+ ) -> PathsResult {
+ self.paths_impl(origins, targets, max_depth, limit, |_| true)
+ }
+
+ /// [`Context::paths`] with a set of relation labels hidden from the
+ /// walk entirely — never a hop in a trail, never a bridge. ADR 0009
+ /// §6.3's traversal exclusion for the reserved `schema:type` label,
+ /// on the same reasoning as [`Context::explore_excluding`]: a hub
+ /// label would put every typed instance a couple of hops from every
+ /// other, and "A relates to B because both are typed 会社" is exactly
+ /// the non-answer a paths query exists to avoid. Additive alongside
+ /// [`Context::paths`] for the same published-API reason the other
+ /// `_excluding` variants document, sharing [`Context::paths_impl`]
+ /// monomorphized per call site on the `visible` closure (see
+ /// [`Context::explore_excluding`] for why that is not cosmetic).
+ pub fn paths_excluding(
+ &self,
+ origins: &[&str],
+ targets: &[&str],
+ max_depth: usize,
+ limit: usize,
+ excluded: &[&str],
+ ) -> PathsResult {
+ let excluded_ids: HashSet = excluded
+ .iter()
+ .filter_map(|name| self.label_ids.get(*name).copied())
+ .collect();
+ self.paths_impl(origins, targets, max_depth, limit, move |label: LabelId| {
+ !excluded_ids.contains(&label)
+ })
+ }
+
+ fn paths_impl(
+ &self,
+ origins: &[&str],
+ targets: &[&str],
+ max_depth: usize,
+ limit: usize,
+ visible: impl Fn(LabelId) -> bool,
+ ) -> PathsResult {
+ let empty = PathsResult {
+ total: 0,
+ capped: false,
+ trails: Vec::new(),
+ };
+ let target_ids: HashSet = targets
+ .iter()
+ .filter_map(|name| self.concept_ids.get(*name).copied())
+ .collect();
+ // Origins keep caller order (deduplicated) so enumeration order —
+ // which decides WHAT gets found when the budget bites — is a
+ // function of the request, not of hash iteration.
+ let mut seen_origins: HashSet = HashSet::new();
+ let origin_ids: Vec = origins
+ .iter()
+ .filter_map(|name| self.concept_ids.get(*name).copied())
+ .filter(|&id| seen_origins.insert(id))
+ .collect();
+ if origin_ids.is_empty() || target_ids.is_empty() || max_depth == 0 || limit == 0 {
+ return empty;
+ }
+
+ // Reverse breadth-first sweep from every target: each node's
+ // distance to its NEAREST target, the admissible bound that lets
+ // the forward walk prune any branch that can no longer reach a
+ // target within `max_depth`. Without this the walk would wander
+ // the whole component before discovering a branch is hopeless.
+ let mut to_target: HashMap = HashMap::new();
+ let mut frontier: VecDeque = VecDeque::new();
+ for &id in &target_ids {
+ to_target.insert(id, 0);
+ frontier.push_back(id);
+ }
+ while let Some(concept) = frontier.pop_front() {
+ let hop = to_target[&concept] + 1;
+ if hop > max_depth {
+ continue;
+ }
+ for edge_id in self.outgoing(concept).chain(self.incoming(concept)) {
+ let edge = &self.edges[edge_id as usize];
+ if edge.count == 0 || !visible(edge.label) {
+ continue;
+ }
+ for neighbor in [edge.subject, edge.object] {
+ if let Entry::Vacant(entry) = to_target.entry(neighbor) {
+ entry.insert(hop);
+ frontier.push_back(neighbor);
+ }
+ }
+ }
+ }
+
+ // Depth-first enumeration of simple paths, pruned by the reverse
+ // distances. Iterative — with the budget rather than `max_depth`
+ // as the real recursion bound, a deep chain could otherwise grow
+ // the call stack past its limit.
+ let mut found: Vec<(Vec, Vec)> = Vec::new();
+ let mut capped = false;
+ let mut budget = Self::PATHS_EXPANSION_BUDGET;
+ let mut node_stack: Vec = Vec::new();
+ let mut edge_stack: Vec = Vec::new();
+ let mut on_path: HashSet = HashSet::new();
+ 'origins: for &origin in &origin_ids {
+ if !to_target.contains_key(&origin) {
+ continue;
+ }
+ node_stack.clear();
+ edge_stack.clear();
+ on_path.clear();
+ node_stack.push(origin);
+ on_path.insert(origin);
+ let mut frames = Vec::new();
+ frames.push(self.outgoing(origin).chain(self.incoming(origin)));
+ while let Some(frame) = frames.last_mut() {
+ let Some(edge_id) = frame.next() else {
+ frames.pop();
+ if let Some(done) = node_stack.pop() {
+ on_path.remove(&done);
+ }
+ edge_stack.pop();
+ continue;
+ };
+ if budget == 0 {
+ capped = true;
+ break 'origins;
+ }
+ budget -= 1;
+ let edge = &self.edges[edge_id as usize];
+ // Same dead-edge and hidden-label discipline as `explore`:
+ // a retracted association is not a fact, a hidden label is
+ // invisible — neither may be a hop, and skipping here (not
+ // post-filtering) means neither can bridge either.
+ if edge.count == 0 || !visible(edge.label) {
+ continue;
+ }
+ let here = *node_stack.last().expect("stack tracks frames");
+ let next = if edge.subject == here {
+ edge.object
+ } else {
+ edge.subject
+ };
+ // A self-loop leads nowhere new, and revisiting a concept
+ // already on the trail would make the path non-simple.
+ if next == here || on_path.contains(&next) {
+ continue;
+ }
+ let depth = edge_stack.len() + 1;
+ let Some(&remaining) = to_target.get(&next) else {
+ continue;
+ };
+ if depth + remaining > max_depth {
+ continue;
+ }
+ if target_ids.contains(&next) {
+ let mut nodes = node_stack.clone();
+ nodes.push(next);
+ let mut edges = edge_stack.clone();
+ edges.push(edge_id);
+ found.push((nodes, edges));
+ }
+ if depth < max_depth {
+ node_stack.push(next);
+ on_path.insert(next);
+ edge_stack.push(edge_id);
+ frames.push(self.outgoing(next).chain(self.incoming(next)));
+ }
+ }
+ }
+
+ // Rank: distance, then weakest link descending, then ids — which
+ // are insertion order, `explore`'s own same-distance tie-break —
+ // so the order is deterministic for a given `Context` history.
+ let mut ranked: Vec<(usize, f64, Vec, Vec)> = found
+ .into_iter()
+ .map(|(nodes, edges)| {
+ let strength = edges
+ .iter()
+ .map(|&edge_id| self.edges[edge_id as usize].sum.abs())
+ .fold(f64::INFINITY, f64::min);
+ (edges.len(), strength, nodes, edges)
+ })
+ .collect();
+ ranked.sort_unstable_by(|a, b| {
+ a.0.cmp(&b.0)
+ .then_with(|| b.1.total_cmp(&a.1))
+ .then_with(|| a.2.cmp(&b.2))
+ .then_with(|| a.3.cmp(&b.3))
+ });
+ let total = ranked.len();
+ ranked.truncate(limit);
+
+ let trails = ranked
+ .into_iter()
+ .map(|(distance, strength, nodes, edges)| Trail {
+ distance,
+ path: nodes
+ .into_iter()
+ .map(|id| self.concept_name(id).to_string())
+ .collect(),
+ strength,
+ associations: edges
+ .into_iter()
+ .map(|edge_id| self.association(edge_id))
+ .collect(),
+ })
+ .collect();
+ PathsResult {
+ total,
+ capped,
+ trails,
+ }
+ }
+
/// Whether any single association directly connects the two
/// concepts, in either direction under any label. Unknown names are
/// simply not adjacent. Audits use this to tell RELATED apart from
@@ -737,6 +992,205 @@ mod tests {
assert_eq!(reached[0].association, assoc("私", "好き", "りんご", 1.0));
}
+ #[test]
+ fn paths_pulls_the_thread_end_to_end() {
+ let mut context = Context::default();
+ // A chain: 私 → りんご → 果物 → ビタミン
+ context.associate("私", "好き", "りんご", 1.0).unwrap();
+ context.associate("りんご", "分類", "果物", 1.0).unwrap();
+ context.associate("果物", "含む", "ビタミン", 1.0).unwrap();
+
+ let direct = context.paths(&["私"], &["りんご"], 10, 10);
+ assert!(!direct.capped);
+ assert_eq!(direct.total, 1);
+ assert_eq!(direct.trails.len(), 1);
+ assert_eq!(direct.trails[0].distance, 1);
+ assert_eq!(direct.trails[0].path, vec!["私", "りんご"]);
+ assert_eq!(
+ direct.trails[0].associations,
+ vec![assoc("私", "好き", "りんご", 1.0)]
+ );
+
+ let far = context.paths(&["私"], &["ビタミン"], 10, 10);
+ assert_eq!(far.total, 1);
+ assert_eq!(far.trails[0].distance, 3);
+ assert_eq!(far.trails[0].path, vec!["私", "りんご", "果物", "ビタミン"]);
+ assert_eq!(far.trails[0].associations.len(), 3);
+ // The trail's associations come back in walk order.
+ assert_eq!(far.trails[0].associations[0].subject, "私");
+ assert_eq!(far.trails[0].associations[2].object, "ビタミン");
+
+ // A depth ceiling below the shortest connection finds nothing.
+ assert_eq!(context.paths(&["私"], &["ビタミン"], 2, 10).total, 0);
+ }
+
+ #[test]
+ fn paths_walks_against_edge_direction_too() {
+ let mut context = Context::default();
+ // りんご is the *object* of both edges; connecting 私 to 農家 runs
+ // against 育てる's direction on the second hop.
+ context.associate("私", "好き", "りんご", 1.0).unwrap();
+ context.associate("農家", "育てる", "りんご", 1.0).unwrap();
+
+ let result = context.paths(&["私"], &["農家"], 10, 10);
+ assert_eq!(result.total, 1);
+ assert_eq!(result.trails[0].path, vec!["私", "りんご", "農家"]);
+ }
+
+ #[test]
+ fn paths_orders_shorter_trails_before_stronger_long_ones() {
+ let mut context = Context::default();
+ // A weak direct edge and a heavyweight detour: the direct thread
+ // still comes first — distance ranks before strength.
+ context.associate("起点", "弱い関係", "目標", 0.5).unwrap();
+ context.associate("起点", "強い関係", "中継", 5.0).unwrap();
+ context.associate("中継", "強い関係", "目標", 5.0).unwrap();
+
+ let result = context.paths(&["起点"], &["目標"], 10, 10);
+ assert_eq!(result.total, 2);
+ assert_eq!(result.trails[0].distance, 1);
+ assert_eq!(result.trails[0].strength, 0.5);
+ assert_eq!(result.trails[1].distance, 2);
+ assert_eq!(result.trails[1].strength, 5.0);
+ }
+
+ #[test]
+ fn paths_ranks_equal_length_trails_by_their_weakest_link() {
+ let mut context = Context::default();
+ // Two 2-hop routes; the first is weaker overall despite one strong
+ // hop, because a chain is only as reliable as its weakest link.
+ context.associate("起点", "r", "弱い中継", 1.0).unwrap();
+ context.associate("弱い中継", "r", "目標", 5.0).unwrap();
+ context.associate("起点", "r", "強い中継", 3.0).unwrap();
+ context.associate("強い中継", "r", "目標", 5.0).unwrap();
+
+ let result = context.paths(&["起点"], &["目標"], 10, 10);
+ assert_eq!(result.total, 2);
+ assert_eq!(result.trails[0].path[1], "強い中継");
+ assert_eq!(result.trails[0].strength, 3.0);
+ assert_eq!(result.trails[1].path[1], "弱い中継");
+ assert_eq!(result.trails[1].strength, 1.0);
+ }
+
+ #[test]
+ fn paths_strength_rewards_corroboration_over_average_weight() {
+ let mut context = Context::default();
+ // The corroborated hop sums to 2.0 (two assertions of 1.0) while
+ // its averaged weight stays 1.0; the single emphatic 1.5 has the
+ // higher average. Ranking on the raw sum keeps corroboration
+ // ahead — the same discipline activate documents.
+ context.associate("起点", "r", "裏取り", 1.0).unwrap();
+ context.associate("起点", "r", "裏取り", 1.0).unwrap();
+ context.associate("裏取り", "r", "目標", 5.0).unwrap();
+ context.associate("起点", "r", "単発", 1.5).unwrap();
+ context.associate("単発", "r", "目標", 5.0).unwrap();
+
+ let result = context.paths(&["起点"], &["目標"], 10, 10);
+ assert_eq!(result.total, 2);
+ assert_eq!(result.trails[0].path[1], "裏取り");
+ assert_eq!(result.trails[0].strength, 2.0);
+ assert_eq!(result.trails[1].strength, 1.5);
+ }
+
+ #[test]
+ fn paths_does_not_bridge_through_a_retracted_edge() {
+ let mut context = Context::default();
+ context.associate("私", "好き", "りんご", 1.0).unwrap();
+ context.associate("りんご", "分類", "果物", 1.0).unwrap();
+ context
+ .retract_association("りんご", "分類", "果物")
+ .unwrap();
+
+ assert_eq!(context.paths(&["私"], &["果物"], 10, 10).total, 0);
+ }
+
+ #[test]
+ fn paths_enumerates_simple_paths_and_reports_total_past_the_limit() {
+ let mut context = Context::default();
+ // A diamond: exactly two simple paths, never an a↔b zigzag.
+ context.associate("o", "r", "a", 1.0).unwrap();
+ context.associate("o", "r", "b", 1.0).unwrap();
+ context.associate("a", "r", "t", 1.0).unwrap();
+ context.associate("b", "r", "t", 1.0).unwrap();
+
+ let all = context.paths(&["o"], &["t"], 10, 10);
+ assert_eq!(all.total, 2);
+ assert_eq!(all.trails.len(), 2);
+
+ let cut = context.paths(&["o"], &["t"], 10, 1);
+ assert_eq!(cut.total, 2, "total still counts what the limit cut");
+ assert_eq!(cut.trails.len(), 1);
+ }
+
+ #[test]
+ fn paths_records_the_prefix_when_a_trail_passes_through_a_target() {
+ let mut context = Context::default();
+ context.associate("o", "r", "t1", 1.0).unwrap();
+ context.associate("t1", "r", "t2", 1.0).unwrap();
+
+ let result = context.paths(&["o"], &["t1", "t2"], 10, 10);
+ assert_eq!(result.total, 2);
+ assert_eq!(result.trails[0].path, vec!["o", "t1"]);
+ assert_eq!(result.trails[1].path, vec!["o", "t1", "t2"]);
+ }
+
+ #[test]
+ fn paths_returns_nothing_for_unknowns_zero_depth_or_self() {
+ let mut context = Context::default();
+ context.associate("a", "r", "b", 1.0).unwrap();
+
+ assert_eq!(context.paths(&["未知"], &["b"], 10, 10).total, 0);
+ assert_eq!(context.paths(&["a"], &["未知"], 10, 10).total, 0);
+ assert_eq!(context.paths(&["a"], &["b"], 0, 10).total, 0);
+ assert_eq!(context.paths(&["a"], &["b"], 10, 0).trails.len(), 0);
+ // A concept is never connected to itself by an empty trail, and a
+ // cycle back to the start is not a simple path.
+ assert_eq!(context.paths(&["a"], &["a"], 10, 10).total, 0);
+ }
+
+ #[test]
+ fn paths_excluding_hides_the_label_as_hop_and_bridge() {
+ let mut context = Context::default();
+ // The only connection runs over the hidden label — once as the
+ // sole hop, once as the middle of a longer thread.
+ context.associate("a", "schema:type", "会社", 1.0).unwrap();
+ context.associate("b", "schema:type", "会社", 1.0).unwrap();
+
+ assert_eq!(context.paths(&["a"], &["b"], 10, 10).total, 1);
+ let excluded = context.paths_excluding(&["a"], &["b"], 10, 10, &["schema:type"]);
+ assert_eq!(
+ excluded.total, 0,
+ "a hidden label must be neither a hop nor a bridge"
+ );
+ }
+
+ #[test]
+ fn paths_reports_capped_when_enumeration_exhausts_the_budget() {
+ // A 12-clique holds millions of simple paths between any two
+ // members; enumeration must stop at the budget and say so instead
+ // of pretending the answer is complete.
+ let mut context = Context::default();
+ let names: Vec = (0..12).map(|i| format!("n{i}")).collect();
+ for i in 0..names.len() {
+ for j in (i + 1)..names.len() {
+ context.associate(&names[i], "r", &names[j], 1.0).unwrap();
+ }
+ }
+
+ let result = context.paths(&["n0"], &["n11"], Context::UNBOUNDED, 5);
+ assert!(result.capped, "a clique walk must hit the budget");
+ assert_eq!(result.trails.len(), 5);
+ assert!(result.total >= 5);
+ // Whatever was found before the budget bit still ranks shortest
+ // first.
+ assert!(
+ result
+ .trails
+ .windows(2)
+ .all(|w| w[0].distance <= w[1].distance)
+ );
+ }
+
#[test]
fn activate_ranks_direct_strong_edges_above_weak_ones() {
let mut context = Context::default();
diff --git a/src/llm-protocol.md b/src/llm-protocol.md
index 0250e7f1..46cc9db9 100644
--- a/src/llm-protocol.md
+++ b/src/llm-protocol.md
@@ -62,7 +62,12 @@ answers back into prose are your job.
4. **Expand and rank**: `activate` spreads from origins (strongest
first, `path` shows the route; strength is an ordering within one
call — never compare across calls). `explore` walks structure
- exhaustively with hop-distance annotations.
+ exhaustively with hop-distance annotations. When the question is
+ "how are these two concepts related?", use `paths` instead of
+ eyeballing either: it returns the concrete trails between origins
+ and targets, shortest first, each hop carrying its association and
+ citations — recompose the connection from the trail, and treat a
+ trail as a chain of stored assertions, never as one asserted fact.
5. **Answer from the originals**: attributions from `recall`, `query`,
`explore`, `activate`, and `unreachable_from` already carry a
resolved `section` label and typed citation `locator` (a
@@ -314,6 +319,7 @@ Source code takes the same discipline; only the naming changes.
| POST | `/contexts/{name}/describe` | `{concept}` → label outline (counts per role) plus declared types (`types?`, absent both without an installed schema and for an untyped concept — the two are indistinguishable on purpose) / null |
| POST | `/contexts/{name}/explore` | `{origins, max_depth?, limit?, after?}` → `{total, matches:[{distance, path, association}]}` (hop cap 10, applied when omitted; truncation keeps the nearest) |
| POST | `/contexts/{name}/activate` | `{origins, decay?=0.5, limit?=20}` → `{total, matches:[{strength, path, association}]}` |
+| POST | `/contexts/{name}/paths` | `{origins, targets, max_depth?, limit?=10}` → `{total, capped, matches:[{distance, path, strength, associations}]}` every simple path from an origin to a target, shortest first (hop cap 10, applied when omitted; limit capped at 100); within one length the largest weakest-link \|sum\| ranks first; `capped: true` means enumeration hit the server budget, so `total` is a lower bound |
| POST | `/contexts/{name}/resolve` | `{cue, dice_floor?, semantic_floor?, limit?}` → `[{name, score, tier, kind?, gloss?, types?}]` concept candidates (limit default/ceiling 1000; `types` rides the top 8 candidates only, and is absent both without an installed schema and for a candidate with no type assertion) |
| POST | `/contexts/{name}/resolve_label` | `{cue, dice_floor?, semantic_floor?, limit?}` → `[{name, score, tier, kind?, gloss?}]` relation candidates (limit default/ceiling 1000) |
| POST | `/contexts/{name}/resolve/explain` | `{cue, expected, dice_floor?, semantic_floor?, limit?}` → one verdict for "why didn't (or did) `expected` come back for `cue`", first that applies: `not_in_vocabulary` (nearest stored spellings attached — register an alias?) / `cue_resolved_exactly` (the cue IS another stored spelling; the exact tier answers alone) / `below_floor` (its actual score vs the floor in effect) / `below_cutoff` (rank, plus a `limit_to_reach` verified by rerunning the serve) / `semantic_not_run` / `semantic_below_floor` (gloss cosine vs the semantic floor, or which precondition failed) / `served` — same floors and limit as the resolve call being explained |
diff --git a/src/main.rs b/src/main.rs
index ab5f497c..a0278060 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -847,6 +847,7 @@ fn routes(
.route("/contexts/{name}/describe", post(api::describe))
.route("/contexts/{name}/explore", post(api::explore))
.route("/contexts/{name}/activate", post(api::activate))
+ .route("/contexts/{name}/paths", post(api::paths))
.route("/contexts/{name}/resolve", post(api::resolve))
.route(
"/contexts/{name}/resolve/explain",
diff --git a/src/mcp.rs b/src/mcp.rs
index 907f1a65..9b9458c0 100644
--- a/src/mcp.rs
+++ b/src/mcp.rs
@@ -61,7 +61,7 @@ mod tests {
fn every_advertised_tool_routes_to_a_request() {
let arguments = json!({
"name": "ctx", "context": "ctx", "cue": "x", "concept": "x",
- "origins": ["x"], "associations": [], "passages": {},
+ "origins": ["x"], "targets": ["y"], "associations": [], "passages": {},
"sources": ["s"], "source": "s", "query": "q", "paragraph": 0,
"stream": "{}", "to": "ctx2", "expected": "x",
"subject": "s", "label": "l", "object": "o",
@@ -270,7 +270,7 @@ mod tests {
fn schema_required_body_arguments_are_refused_when_omitted() {
let base = json!({
"name": "ctx", "context": "ctx", "cue": "x", "concept": "x",
- "origins": ["x"], "passages": {}, "sources": ["s"], "source": "s",
+ "origins": ["x"], "targets": ["y"], "passages": {}, "sources": ["s"], "source": "s",
"query": "q", "paragraph": 0, "to": "ctx2", "expected": "x",
"subject": "s", "label": "l", "object": "o",
"schema": 1, "mode": "strict", "closed_labels": false,
@@ -295,6 +295,8 @@ mod tests {
("recall", "cue"),
("activate", "origins"),
("explore", "origins"),
+ ("paths", "origins"),
+ ("paths", "targets"),
("retract_source", "source"),
("retract_association", "subject"),
("retract_association", "label"),
diff --git a/src/mcp/route.rs b/src/mcp/route.rs
index 6661607d..b0d85f89 100644
--- a/src/mcp/route.rs
+++ b/src/mcp/route.rs
@@ -289,6 +289,19 @@ pub fn route_tool(
Some(pick(arguments, &["origins", "max_depth", "limit", "after"])),
)
}
+ "paths" => {
+ let path = format!("{}/paths", context_path("context")?);
+ need_present(arguments, "origins")?;
+ need_present(arguments, "targets")?;
+ (
+ "POST",
+ path,
+ Some(pick(
+ arguments,
+ &["origins", "targets", "max_depth", "limit"],
+ )),
+ )
+ }
"list_labels" => (
"GET",
format!(
diff --git a/src/mcp/schema.rs b/src/mcp/schema.rs
index dabf5a1a..b889214a 100644
--- a/src/mcp/schema.rs
+++ b/src/mcp/schema.rs
@@ -420,6 +420,20 @@ pub(super) fn tool_definitions() -> Vec {
&["context", "origins"],
),
),
+ (
+ "paths",
+ "How are two concepts related? Every simple path from an origin to a target, shortest first — the whole concept trail plus each hop's association with its citations. Within one length, the reliablest chain (largest weakest-link |sum|) ranks first. capped=true means enumeration hit the server budget, so total is a lower bound.",
+ object_schema(
+ json!({
+ "context": context,
+ "origins": { "type": "array", "items": { "type": "string" } },
+ "targets": { "type": "array", "items": { "type": "string" } },
+ "max_depth": { "type": "integer", "description": "hop ceiling per trail; default and max 10" },
+ "limit": { "type": "integer", "minimum": 0, "description": "max trails (default 10, capped at 100)" }
+ }),
+ &["context", "origins", "targets"],
+ ),
+ ),
(
"explore",
"Exhaustive structural walk with hop distances, for unranked neighborhood views. Truncation keeps the nearest hops (watch total).",
diff --git a/src/metrics/taxonomy.rs b/src/metrics/taxonomy.rs
index 5743ecf1..ed8a8296 100644
--- a/src/metrics/taxonomy.rs
+++ b/src/metrics/taxonomy.rs
@@ -49,10 +49,11 @@ pub enum SearchOp {
SearchPassages,
SearchCommunities,
Explore,
+ Paths,
}
impl SearchOp {
- pub(super) const ALL: [SearchOp; 8] = [
+ pub(super) const ALL: [SearchOp; 9] = [
SearchOp::Resolve,
SearchOp::ResolveLabel,
SearchOp::Recall,
@@ -61,6 +62,7 @@ impl SearchOp {
SearchOp::SearchPassages,
SearchOp::SearchCommunities,
SearchOp::Explore,
+ SearchOp::Paths,
];
/// `pub(crate)`: also `taguru.op`'s source of truth on the spans
@@ -83,6 +85,7 @@ impl SearchOp {
SearchOp::SearchPassages => "search_passages",
SearchOp::SearchCommunities => "search_communities",
SearchOp::Explore => "explore",
+ SearchOp::Paths => "paths",
}
}
}
diff --git a/tests/fixtures/wire/http/paths.json b/tests/fixtures/wire/http/paths.json
new file mode 100644
index 00000000..14893298
--- /dev/null
+++ b/tests/fixtures/wire/http/paths.json
@@ -0,0 +1,55 @@
+{
+ "contract": "http_contract",
+ "method": "POST",
+ "operation": "paths",
+ "request": {
+ "origins": [
+ "alpha"
+ ],
+ "targets": [
+ "beta"
+ ]
+ },
+ "response": {
+ "result": {
+ "capped": false,
+ "matches": [
+ {
+ "associations": [
+ {
+ "attributions": [
+ {
+ "count": 1,
+ "locator": {
+ "kind": "page",
+ "value": "1"
+ },
+ "paragraph": 0,
+ "section": null,
+ "source": "doc.md",
+ "weight": 2.0
+ }
+ ],
+ "count": 1,
+ "label": "connects_to",
+ "object": "beta",
+ "subject": "alpha",
+ "weight": 2.0
+ }
+ ],
+ "distance": 1,
+ "path": [
+ "alpha",
+ "beta"
+ ],
+ "strength": 2.0
+ }
+ ],
+ "total": 1
+ },
+ "status": "ok",
+ "time": 0.0
+ },
+ "route": "/contexts/{name}/paths",
+ "status": 200
+}
diff --git a/tests/fixtures/wire/shapes.json b/tests/fixtures/wire/shapes.json
index c5138075..5e0245c7 100644
--- a/tests/fixtures/wire/shapes.json
+++ b/tests/fixtures/wire/shapes.json
@@ -12,6 +12,7 @@
"/contexts/{name}/recall": ["cue"],
"/contexts/{name}/explore": ["origins"],
"/contexts/{name}/activate": ["origins"],
+ "/contexts/{name}/paths": ["origins", "targets"],
"/contexts/{name}/sources/search": ["query"],
"/contexts/{name}/communities/search": ["query"],
"/contexts/{name}/evidence": ["origins"]
diff --git a/tests/http_api/contract.rs b/tests/http_api/contract.rs
index 25af0a3d..abeacefe 100644
--- a/tests/http_api/contract.rs
+++ b/tests/http_api/contract.rs
@@ -272,6 +272,22 @@ fn explore_and_activate_pages() {
status,
body,
);
+
+ let request = json!({"origins": ["alpha"], "targets": ["beta"]});
+ let (status, body) = server.call("POST", "/contexts/corpus-b/paths", Some(request.clone()));
+ assert_eq!(status, 200, "{body}");
+ assert!(
+ !body["result"]["matches"].as_array().unwrap().is_empty(),
+ "{body}"
+ );
+ http_fixture(
+ "paths",
+ "POST",
+ "/contexts/{name}/paths",
+ Some(request),
+ status,
+ body,
+ );
}
// --- HTTP: passage and community search — PassagePage is the 0.4.0
diff --git a/tests/http_api/mcp_basics.rs b/tests/http_api/mcp_basics.rs
index b41f3e12..7ece8be9 100644
--- a/tests/http_api/mcp_basics.rs
+++ b/tests/http_api/mcp_basics.rs
@@ -113,6 +113,54 @@ fn mcp_over_http_serves_initialize_tools_and_calls() {
assert_eq!(status, 405);
}
+/// The paths tool dispatches onto POST /contexts/{name}/paths and the
+/// pass-through body carries the trail — concept path plus each hop's
+/// association — like every other tool result (ADR 0005 §2.4).
+#[test]
+fn paths_tool_executes_end_to_end_through_mcp() {
+ let server = Server::start("mcp-paths");
+ server.ok("PUT", "/contexts/sake", None);
+ server.ok(
+ "POST",
+ "/contexts/sake/associations",
+ Some(json!([
+ {"subject": "青嶺酒造", "label": "杜氏", "object": "高瀬", "weight": 1.0},
+ {"subject": "高瀬", "label": "出身", "object": "南部杜氏", "weight": 1.0},
+ ])),
+ );
+
+ let (status, reply) = server.call(
+ "POST",
+ "/mcp",
+ Some(json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call",
+ "params": {"name": "paths",
+ "arguments": {"context": "sake",
+ "origins": ["青嶺酒造"],
+ "targets": ["南部杜氏"]}}})),
+ );
+ assert_eq!(status, 200);
+ assert!(reply["result"].get("isError").is_none(), "{reply}");
+ let text = reply["result"]["content"][0]["text"].as_str().unwrap();
+ let body: Value = serde_json::from_str(text).unwrap();
+ assert_eq!(body["result"]["total"], json!(1), "{body}");
+ assert_eq!(
+ body["result"]["matches"][0]["path"],
+ json!(["青嶺酒造", "高瀬", "南部杜氏"]),
+ "{body}"
+ );
+
+ // Omitting targets is a tool-level error, never a JSON-RPC abort.
+ let (status, refused) = server.call(
+ "POST",
+ "/mcp",
+ Some(json!({"jsonrpc": "2.0", "id": 2, "method": "tools/call",
+ "params": {"name": "paths",
+ "arguments": {"context": "sake", "origins": ["青嶺酒造"]}}})),
+ );
+ assert_eq!(status, 200);
+ assert_eq!(refused["result"]["isError"], json!(true), "{refused}");
+}
+
/// /mcp sits behind the bearer token like every route — and a tool
/// dispatched through it is NOT re-authenticated inside; the /mcp
/// entry is the auth point.
diff --git a/tests/http_api/retrieval_core.rs b/tests/http_api/retrieval_core.rs
index a15c8fe4..60902943 100644
--- a/tests/http_api/retrieval_core.rs
+++ b/tests/http_api/retrieval_core.rs
@@ -277,6 +277,28 @@ fn full_retrieval_loop_over_http() {
.iter()
.any(|r| r["distance"] == json!(2) && r["path"] == json!(["青嶺酒造", "高瀬"]))
);
+ // paths pulls the thread end to end: the whole concept trail plus
+ // every association along it, in walk order.
+ let threads = server.ok(
+ "POST",
+ "/contexts/sake/paths",
+ Some(json!({"origins": ["青嶺酒造"], "targets": ["南部杜氏"]})),
+ );
+ assert_eq!(threads["total"], json!(1));
+ assert_eq!(threads["capped"], json!(false));
+ assert_eq!(threads["matches"][0]["distance"], json!(2));
+ assert_eq!(
+ threads["matches"][0]["path"],
+ json!(["青嶺酒造", "高瀬", "南部杜氏"])
+ );
+ assert_eq!(
+ threads["matches"][0]["associations"][0]["label"],
+ json!("杜氏")
+ );
+ assert_eq!(
+ threads["matches"][0]["associations"][1]["label"],
+ json!("出身")
+ );
// Aliases resolve at entry, answer with canonical spellings, and
// refuse to shadow existing spellings.
diff --git a/tests/http_api/schema_type_label.rs b/tests/http_api/schema_type_label.rs
index fb8bda09..7fa2dda6 100644
--- a/tests/http_api/schema_type_label.rs
+++ b/tests/http_api/schema_type_label.rs
@@ -132,6 +132,44 @@ fn schema_type_is_ordinary_until_a_schema_exists_then_hidden_from_labels_and_tra
assert!(body["error"].as_str().unwrap().contains("rename"), "{body}");
}
+/// `paths`' own copy of the same exclusion: before a schema exists,
+/// two concepts sharing a type object are two hops apart through it;
+/// once one installs (mode `off` included), that thread must vanish —
+/// "A relates to B because both are typed Brewery" is exactly the
+/// non-answer the exclusion exists to prevent.
+#[test]
+fn paths_never_threads_through_schema_type_once_a_schema_exists() {
+ let server = Server::start("schema-type-label-paths");
+ server.ok("PUT", "/contexts/sake", Some(json!({"description": "d"})));
+ server.ok(
+ "POST",
+ "/contexts/sake/associations",
+ Some(json!([
+ {"subject": "青嶺酒造", "label": "schema:type", "object": "Brewery",
+ "weight": 1.0, "source": "a.md"},
+ {"subject": "旧銘酒造", "label": "schema:type", "object": "Brewery",
+ "weight": 1.0, "source": "a.md"},
+ ])),
+ );
+
+ let request = json!({"origins": ["青嶺酒造"], "targets": ["旧銘酒造"]});
+ let before = server.ok("POST", "/contexts/sake/paths", Some(request.clone()));
+ assert_eq!(
+ before["total"],
+ json!(1),
+ "before a schema exists, schema:type threads like any label: {before}"
+ );
+
+ server.ok("PUT", "/contexts/sake/schema", Some(off_document()));
+
+ let after = server.ok("POST", "/contexts/sake/paths", Some(request));
+ assert_eq!(
+ after["total"],
+ json!(0),
+ "once a schema exists, schema:type must never be a hop in a trail: {after}"
+ );
+}
+
/// `activate`'s ranked sibling of the same exclusion — the fan
/// normalization and the propagation walk must both drop `schema:type`
/// edges, or a heavily-typed concept's real facts would be diluted by
From f4f50ffacedb2d1720c65d8043cb504c567d0216 Mon Sep 17 00:00:00 2001
From: Takashi Yamashina
Date: Wed, 5 Aug 2026 14:12:44 +0900
Subject: [PATCH 2/2] review: declare minimum: 0 on max_depth in the MCP tool
schemas
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
CodeRabbit on PR #424: the paths tool's max_depth declared only
"integer", so a negative value would pass MCP schema validation and
surface as a 422 deserialization failure server-side — the exact
failure mode every limit property's minimum: 0 exists to catch early.
Applied to explore's max_depth too, which had the same gap. No
maximum: 10, deliberately: the server clamps larger depths rather
than refusing them (same contract as every limit, which caps at 1000
while declaring only the minimum), and a schema maximum would make
MCP refuse what HTTP accepts.
Claude-Session: https://claude.ai/code/session_014RfogjbkTt5f14rz8fzYgP
---
src/mcp/schema.rs | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/mcp/schema.rs b/src/mcp/schema.rs
index b889214a..e6788d35 100644
--- a/src/mcp/schema.rs
+++ b/src/mcp/schema.rs
@@ -428,7 +428,7 @@ pub(super) fn tool_definitions() -> Vec {
"context": context,
"origins": { "type": "array", "items": { "type": "string" } },
"targets": { "type": "array", "items": { "type": "string" } },
- "max_depth": { "type": "integer", "description": "hop ceiling per trail; default and max 10" },
+ "max_depth": { "type": "integer", "minimum": 0, "description": "hop ceiling per trail; default and max 10 (larger values are clamped, not refused)" },
"limit": { "type": "integer", "minimum": 0, "description": "max trails (default 10, capped at 100)" }
}),
&["context", "origins", "targets"],
@@ -441,7 +441,7 @@ pub(super) fn tool_definitions() -> Vec {
json!({
"context": context,
"origins": { "type": "array", "items": { "type": "string" } },
- "max_depth": { "type": "integer", "description": "hop ceiling; default and max 10" },
+ "max_depth": { "type": "integer", "minimum": 0, "description": "hop ceiling; default and max 10 (larger values are clamped, not refused)" },
"limit": { "type": "integer", "minimum": 0, "description": "default 100, capped at 1000" },
"after": {
"type": "object",