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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions docs/concepts.html
Original file line number Diff line number Diff line change
Expand Up @@ -150,8 +150,8 @@ <h2>Groups bundle contexts — and searches cross them</h2>
<code>context</code> 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 (<code>activate</code>, <code>explore</code>,
<code>resolve</code>) stay per-context by design: search across, then pull the thread
inside the context that answered.
<code>paths</code>, <code>resolve</code>) stay per-context by design: search across, then
pull the thread inside the context that answered.
</p>
<p class="note">
Worked mappings — a paper split into section contexts and chapter groups, parts as
Expand Down Expand Up @@ -185,7 +185,7 @@ <h2>The retrieval loop</h2>
<div class="loop-step"><span class="n">01</span><span class="t">Select</span><span class="d">pick the context with list_contexts</span></div>
<div class="loop-step"><span class="n">02</span><span class="t">Resolve</span><span class="d">resolve / resolve_label turn the cue into canonical names</span></div>
<div class="loop-step"><span class="n">03</span><span class="t">Survey</span><span class="d">describe → query to narrow down</span></div>
<div class="loop-step"><span class="n">04</span><span class="t">Expand</span><span class="d">activate / explore gather related knowledge</span></div>
<div class="loop-step"><span class="n">04</span><span class="t">Expand</span><span class="d">activate / explore gather related knowledge; paths threads two concepts together</span></div>
<div class="loop-step"><span class="n">05</span><span class="t">Verify</span><span class="d">cite_passage checks the original text</span></div>
<div class="loop-step" style="border-bottom-color: var(--thread-soft);"><span class="n" style="color:var(--thread)">06</span><span class="t">Cast the net</span><span class="d">search_passages when the graph can't reach</span></div>
</div>
Expand Down
3 changes: 2 additions & 1 deletion docs/modeling.html
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,8 @@ <h2>Searching across papers names groups, not a super-context</h2>
<p>
The response's <code>plan</code> names exactly which contexts the two groups expanded to, so
"which paper answered" is never a guess. Deep dives — <code>activate</code>,
<code>explore</code>, <code>resolve</code>, and the composite <code>retrieve</code> tool —
<code>explore</code>, <code>paths</code>, <code>resolve</code>, and the composite
<code>retrieve</code> tool —
stay <b>per-context by design</b> (see <a href="concepts.html#groups">Concepts</a>): search
across with a cross call, then pull the thread inside whichever context answered. Reaching
for <code>retrieve</code> against one context after deliberately splitting a corpus into
Expand Down
4 changes: 4 additions & 0 deletions sdk/python/src/taguru/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@
PassageLanes,
PassageLookup,
PassagePage,
PathsPage,
RankingExplain,
Recollection,
RefreshBreakdown,
Expand All @@ -126,6 +127,7 @@
StoredPassages,
TermContribution,
TieredResolution,
Trail,
TwinPair,
TypeDef,
UnsourcedEdge,
Expand Down Expand Up @@ -257,6 +259,7 @@
"PassageLanes",
"PassageLookup",
"PassagePage",
"PathsPage",
"RankingExplain",
"Recollection",
"RefreshBreakdown",
Expand All @@ -283,6 +286,7 @@
"StoredPassages",
"TermContribution",
"TieredResolution",
"Trail",
"TwinPair",
"TypeDef",
"UnsourcedEdge",
Expand Down
26 changes: 26 additions & 0 deletions sdk/python/src/taguru/_async/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
PassageHit,
PassageLookup,
PassagePage,
PathsPage,
RefreshOutcome,
ResolveExplanation,
RetractAssociationOutcome,
Expand Down Expand Up @@ -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],
Expand Down
23 changes: 23 additions & 0 deletions sdk/python/src/taguru/_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"``.
Expand Down
26 changes: 26 additions & 0 deletions sdk/python/src/taguru/_sync/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
PassageHit,
PassageLookup,
PassagePage,
PathsPage,
RefreshOutcome,
ResolveExplanation,
RetractAssociationOutcome,
Expand Down Expand Up @@ -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],
Expand Down
6 changes: 6 additions & 0 deletions sdk/python/tests/integration/test_full_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions sdk/python/tests/unit/test_wire_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
ExplorePage,
MatchPage,
PassagePage,
PathsPage,
)

# sdk/python/tests/unit/test_wire_contract.py -> repo root: same depth
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion sdk/python/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions sdk/spec/surface.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
24 changes: 24 additions & 0 deletions sdk/typescript/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import type {
PassageHit,
PassageLookup,
PassagePage,
PathsPage,
QuestionSpec,
RefreshOutcome,
RerankRequest,
Expand Down Expand Up @@ -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<PathsPage> {
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[],
Expand Down
2 changes: 2 additions & 0 deletions sdk/typescript/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ export {
type PassageLanes,
type PassageLookup,
type PassagePage,
type PathsPage,
type QuestionSpec,
type RankingExplain,
type Recollection,
Expand Down Expand Up @@ -139,6 +140,7 @@ export {
type StoredPassages,
type TermContribution,
type TieredResolution,
type Trail,
type TwinPair,
type TypeDef,
type UnsourcedEdge,
Expand Down
23 changes: 23 additions & 0 deletions sdk/typescript/src/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions sdk/typescript/tests/integration/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
1 change: 1 addition & 0 deletions sdk/typescript/tests/unit/wire-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ const TYPED_OPERATIONS = [
"sources_search",
"explore",
"activate",
"paths",
"communities_search",
"evidence_mixed_lanes",
"evidence_budget_constrained",
Expand Down
Loading