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