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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- **Shortest relation paths**: Added `jumbo relations path` with typed or uniquely inferred endpoints, deterministic bounded breadth-first search, direction and relation filters, provenance-preserving text output, and a stable structured result for connected or disconnected memories.

## [3.18.0] - 2026-07-23

### Added
Expand Down
40 changes: 39 additions & 1 deletion docs/reference/commands/relations.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ sidebar:
order: 12
---

Add, list, traverse, and remove relationships between entities in the knowledge graph — linking goals, components, decisions, and other entities.
Add, list, traverse, find paths through, and remove relationships between entities in the knowledge graph — linking goals, components, decisions, and other entities.

---

Expand Down Expand Up @@ -124,6 +124,44 @@ If an ID appears under multiple entity types, specify `--entity-type`. Results i

---

## jumbo relations path

Find one deterministic, unweighted shortest path between two relation endpoints. The query uses bounded breadth-first search, preserves each relation's original direction and description, and treats strength as filter metadata rather than path cost.

### Synopsis

```bash
> jumbo relations path --from-id <id> --to-id <id> [options]
```

### Options

| Option | Description |
|--------|-------------|
| `--from-id <id>` | Starting entity ID (required) |
| `--to-id <id>` | Destination entity ID (required) |
| `--from-type <type>` | Starting entity type; inferred when the ID identifies one endpoint type |
| `--to-type <type>` | Destination entity type; inferred when the ID identifies one endpoint type |
| `--max-depth <depth>` | Maximum path depth from `1` through `5` (default: `5`) |
| `-d, --direction <direction>` | Traversal direction: `in`, `out`, or `both` (default: `both`) |
| `--relation-type <type>` | Filter by relation type |
| `--entity-type <type>` | Filter each traversed opposite endpoint by entity type |
| `--strength <strength>` | Filter by strength: `strong`, `medium`, or `weak` |
| `-s, --status <status>` | Filter by status: `active`, `deactivated`, `removed`, or `all` (default: `active`) |

If either ID appears under multiple entity types, specify its matching type option. A disconnected pair is a successful query with `found: false` and empty path data. Text output renders typed endpoint IDs with arrows that retain original relation orientation; `--format json` returns resolved endpoints, hop count, ordered nodes and edges, and query metadata.

### Examples

```bash
> jumbo relations path --from-type goal --from-id goal_abc123 --to-type dependency --to-id dep_def456
> jumbo relations path --from-id goal_abc123 --to-id comp_def456 --direction out --max-depth 3
> jumbo relations path --from-id goal_abc123 --to-id comp_def456 --relation-type involves --strength strong
> jumbo relations path --from-id goal_abc123 --to-id comp_def456 --format json
```

---

## jumbo relation remove

Remove a relation from the knowledge graph.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { FindRelationPathRequest } from "./FindRelationPathRequest.js";
import { IFindRelationPathGateway } from "./IFindRelationPathGateway.js";
import { RelationPathResult } from "./RelationPathResult.js";

export class FindRelationPathController {
constructor(private readonly gateway: IFindRelationPathGateway) {}

async handle(request: FindRelationPathRequest): Promise<RelationPathResult> {
return this.gateway.findPath(request);
}
}
19 changes: 19 additions & 0 deletions src/application/context/relations/path/FindRelationPathRequest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import {
EntityTypeValue,
RelationStrengthValue,
} from "../../../../domain/relations/Constants.js";
import { RelationDirection } from "../get/RelationDirection.js";
import { RelationStatusFilter } from "../get/RelationStatusFilter.js";

export interface FindRelationPathRequest {
readonly fromEntityId: string;
readonly fromEntityType?: EntityTypeValue;
readonly toEntityId: string;
readonly toEntityType?: EntityTypeValue;
readonly maxDepth?: number;
readonly direction?: RelationDirection;
readonly relationType?: string;
readonly relatedEntityType?: EntityTypeValue;
readonly strength?: RelationStrengthValue;
readonly status?: RelationStatusFilter;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { FindRelationPathRequest } from "./FindRelationPathRequest.js";
import { RelationPathResult } from "./RelationPathResult.js";

export interface IFindRelationPathGateway {
findPath(request: FindRelationPathRequest): Promise<RelationPathResult>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { RelationNodeReferenceResolver } from "../traverse/RelationNodeReferenceResolver.js";
import { RelationTraversalQueryNormalizer } from "../traverse/RelationTraversalQueryNormalizer.js";
import { FindRelationPathRequest } from "./FindRelationPathRequest.js";
import { IFindRelationPathGateway } from "./IFindRelationPathGateway.js";
import { RelationPathResult } from "./RelationPathResult.js";
import { RelationShortestPathFinder } from "./RelationShortestPathFinder.js";

export class LocalFindRelationPathGateway implements IFindRelationPathGateway {
constructor(
private readonly nodeResolver: RelationNodeReferenceResolver,
private readonly queryNormalizer: RelationTraversalQueryNormalizer,
private readonly shortestPathFinder: RelationShortestPathFinder,
) {}

async findPath(
request: FindRelationPathRequest,
): Promise<RelationPathResult> {
const query = this.queryNormalizer.normalize(
{
depth: request.maxDepth,
direction: request.direction,
relationType: request.relationType,
relatedEntityType: request.relatedEntityType,
strength: request.strength,
status: request.status,
},
5,
);
const from = await this.nodeResolver.resolve(
request.fromEntityId,
request.fromEntityType,
"--from-type",
"From entity",
);
const to = await this.nodeResolver.resolve(
request.toEntityId,
request.toEntityType,
"--to-type",
"To entity",
);

return this.shortestPathFinder.find(from, to, query);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import {
EntityTypeValue,
RelationStrengthValue,
} from "../../../../domain/relations/Constants.js";
import { RelationDirection } from "../get/RelationDirection.js";
import { RelationStatusFilter } from "../get/RelationStatusFilter.js";

export interface RelationPathQueryMetadata {
readonly maxDepth: number;
readonly direction: RelationDirection;
readonly relationType?: string;
readonly entityType?: EntityTypeValue;
readonly strength?: RelationStrengthValue;
readonly status: RelationStatusFilter;
}
13 changes: 13 additions & 0 deletions src/application/context/relations/path/RelationPathResult.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { RelationView } from "../RelationView.js";
import { RelationNodeReference } from "../get/RelationNodeReference.js";
import { RelationPathQueryMetadata } from "./RelationPathQueryMetadata.js";

export interface RelationPathResult {
readonly found: boolean;
readonly from: RelationNodeReference;
readonly to: RelationNodeReference;
readonly hopCount: number;
readonly nodes: RelationNodeReference[];
readonly edges: RelationView[];
readonly query: RelationPathQueryMetadata;
}
180 changes: 180 additions & 0 deletions src/application/context/relations/path/RelationShortestPathFinder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import { RelationView } from "../RelationView.js";
import { IRelationViewReader } from "../get/IRelationViewReader.js";
import { RelationNodeReference } from "../get/RelationNodeReference.js";
import { RelationTraversalPolicy } from "../traverse/RelationTraversalPolicy.js";
import { RelationTraversalQuery } from "../traverse/RelationTraversalQuery.js";
import { RelationPathQueryMetadata } from "./RelationPathQueryMetadata.js";
import { RelationPathResult } from "./RelationPathResult.js";

type PathPredecessor = {
readonly previous: RelationNodeReference;
readonly edge: RelationView;
};

type PathTransition = {
readonly node: RelationNodeReference;
readonly edge: RelationView;
};

export class RelationShortestPathFinder {
constructor(
private readonly relationViewReader: IRelationViewReader,
private readonly traversalPolicy = new RelationTraversalPolicy(),
) {}

async find(
from: RelationNodeReference,
to: RelationNodeReference,
query: RelationTraversalQuery,
): Promise<RelationPathResult> {
const queryMetadata = this.toQueryMetadata(query);
if (
this.traversalPolicy.nodeKey(from) === this.traversalPolicy.nodeKey(to)
) {
return this.foundResult(from, to, [from], [], queryMetadata);
}

const visited = new Set<string>([this.traversalPolicy.nodeKey(from)]);
const predecessors = new Map<string, PathPredecessor>();
let frontier: RelationNodeReference[] = [from];

for (let depth = 1; depth <= query.depth && frontier.length > 0; depth++) {
const next = new Map<string, RelationNodeReference>();
const stableFrontier = [...frontier].sort((left, right) =>
this.traversalPolicy.compareNodes(left, right),
);

for (const current of stableFrontier) {
const adjacent = await this.relationViewReader.findAll({
entity: current,
direction: query.direction,
relationType: query.relationType,
relatedEntityType: query.relatedEntityType,
strength: query.strength,
status: query.status,
});
const transitions = this.stableTransitions(current, adjacent, query);

for (const transition of transitions) {
const key = this.traversalPolicy.nodeKey(transition.node);
if (visited.has(key)) continue;

visited.add(key);
predecessors.set(key, { previous: current, edge: transition.edge });

if (key === this.traversalPolicy.nodeKey(to)) {
const path = this.reconstruct(from, to, predecessors);
return this.foundResult(
from,
to,
path.nodes,
path.edges,
queryMetadata,
);
}

next.set(key, transition.node);
}
}

frontier = [...next.values()];
}

return {
found: false,
from,
to,
hopCount: 0,
nodes: [],
edges: [],
query: queryMetadata,
};
}

private stableTransitions(
current: RelationNodeReference,
adjacent: RelationView[],
query: RelationTraversalQuery,
): PathTransition[] {
return adjacent
.map((edge): PathTransition | undefined => {
const node = this.traversalPolicy.relatedNode(
current,
edge,
query.direction,
);
return node ? { node, edge } : undefined;
})
.filter(
(transition): transition is PathTransition => transition !== undefined,
)
.sort(
(left, right) =>
this.traversalPolicy.compareNodes(left.node, right.node) ||
this.traversalPolicy.compareEdges(left.edge, right.edge),
);
}

private reconstruct(
from: RelationNodeReference,
to: RelationNodeReference,
predecessors: Map<string, PathPredecessor>,
): { nodes: RelationNodeReference[]; edges: RelationView[] } {
const reversedNodes: RelationNodeReference[] = [to];
const reversedEdges: RelationView[] = [];
let cursor = to;

while (
this.traversalPolicy.nodeKey(cursor) !==
this.traversalPolicy.nodeKey(from)
) {
const predecessor = predecessors.get(
this.traversalPolicy.nodeKey(cursor),
);
if (!predecessor) {
throw new Error(
"Relation path reconstruction encountered an incomplete predecessor chain.",
);
}
reversedEdges.push(predecessor.edge);
reversedNodes.push(predecessor.previous);
cursor = predecessor.previous;
}

return {
nodes: reversedNodes.reverse(),
edges: reversedEdges.reverse(),
};
}

private foundResult(
from: RelationNodeReference,
to: RelationNodeReference,
nodes: RelationNodeReference[],
edges: RelationView[],
query: RelationPathQueryMetadata,
): RelationPathResult {
return {
found: true,
from,
to,
hopCount: edges.length,
nodes,
edges,
query,
};
}

private toQueryMetadata(
query: RelationTraversalQuery,
): RelationPathQueryMetadata {
return {
maxDepth: query.depth,
direction: query.direction,
relationType: query.relationType,
entityType: query.relatedEntityType,
strength: query.strength,
status: query.status,
};
}
}
Loading
Loading