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..e6788d35 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", "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"],
+ ),
+ ),
(
"explore",
"Exhaustive structural walk with hop distances, for unranked neighborhood views. Truncation keeps the nearest hops (watch total).",
@@ -427,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",
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