katalyst-plugin-context 0.1.1a1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,762 @@
1
+ """The context of one node: the map around it, and the items an agent asks for.
2
+
3
+ Pure: it walks a :class:`~katalyst_plugin_context.domain.graph.Graph` under a
4
+ :class:`~katalyst_plugin_context.domain.policy.Policy`, and asks a :class:`Schema` what each
5
+ kind declares. The graph, the policy and the schema are built by the service per call.
6
+ Nothing here names a kind, a relationship or a field.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import math
13
+ from collections.abc import Callable, Mapping, Sequence
14
+ from dataclasses import dataclass, field
15
+ from typing import Any, Protocol
16
+
17
+ from katalyst_plugin_context.domain.graph import Graph, GraphNode
18
+ from katalyst_plugin_context.domain.paths import OUT, Step, parse_path, path_text
19
+ from katalyst_plugin_context.domain.policy import DETAIL_LEVELS, FULL, REFS, SUMMARY, Policy
20
+ from katalyst_plugin_context.domain.settings import ItemHints, View
21
+
22
+ __all__ = [
23
+ "ContextError",
24
+ "ContextRequest",
25
+ "Schema",
26
+ "build_map",
27
+ "build_result",
28
+ "estimate_tokens",
29
+ ]
30
+
31
+ #: How much of a description ``summary`` shows, and of each summary field's value.
32
+ DESCRIPTION_LIMIT = 300
33
+ FIELD_LIMIT = 120
34
+ #: At most this many arrivals per walk: a bound on a pathological fan-out, far above any budget.
35
+ WALK_LIMIT = 5000
36
+ #: At most this many qualified names per group in the "what was cut" report.
37
+ CUT_NAMES = 20
38
+
39
+ _SCALARS = ("string", "integer", "number", "boolean")
40
+
41
+
42
+ class ContextError(Exception):
43
+ """A request that cannot be answered: ``code`` is the envelope's error code."""
44
+
45
+ def __init__(self, code: str, message: str, details: Mapping[str, Any] | None = None) -> None:
46
+ super().__init__(message)
47
+ self.code = code
48
+ self.message = message
49
+ self.details = dict(details or {})
50
+
51
+
52
+ class Schema(Protocol):
53
+ """What the context needs to know about kinds and relationships."""
54
+
55
+ parent_rel: str
56
+
57
+ def kind_label(self, kind: str) -> str: ...
58
+ def hints(self, kind: str) -> ItemHints: ...
59
+ def declared_fields(self, kind: str) -> Sequence[tuple[str, str]]: ...
60
+ def blocking(self, kind: str, spec: Mapping[str, Any]) -> list[dict[str, Any]]: ...
61
+ def relationship_description(self, rel: str) -> str: ...
62
+ def carried(self, kind: str) -> Sequence[tuple[str, frozenset[str]]]: ...
63
+
64
+
65
+ @dataclass(frozen=True, slots=True)
66
+ class ContextRequest:
67
+ """What an agent asks for. ``None`` means "per policy"."""
68
+
69
+ paths: tuple[str, ...] | None = None
70
+ views: tuple[str, ...] | None = None
71
+ depth: int | None = None
72
+ detail: str | None = None
73
+ budget: int | None = None
74
+ inherit: bool | None = None
75
+ format: str = "json"
76
+
77
+ @property
78
+ def names_nothing(self) -> bool:
79
+ return not self.paths and not self.views
80
+
81
+ def as_payload(self) -> dict[str, Any]:
82
+ return {
83
+ "paths": list(self.paths) if self.paths is not None else None,
84
+ "views": list(self.views) if self.views is not None else None,
85
+ "depth": self.depth,
86
+ "detail": self.detail,
87
+ "budget": self.budget,
88
+ "inherit": self.inherit,
89
+ }
90
+
91
+
92
+ def estimate_tokens(text: str) -> int:
93
+ """Approximate tokens: a quarter of the characters, rounded up."""
94
+ return math.ceil(len(text) / 4)
95
+
96
+
97
+ # ── walking ──────────────────────────────────────────────────────────────
98
+
99
+
100
+ @dataclass(frozen=True, slots=True)
101
+ class _Hop:
102
+ source: str
103
+ rel: str
104
+ direction: str
105
+ target: str
106
+
107
+
108
+ @dataclass(frozen=True, slots=True)
109
+ class _Arrival:
110
+ key: str
111
+ start: str
112
+ hops: tuple[_Hop, ...]
113
+
114
+
115
+ def _walk(
116
+ graph: Graph,
117
+ start: str,
118
+ steps: Sequence[Step],
119
+ policy: Policy,
120
+ *,
121
+ exclude: frozenset[str],
122
+ inherited: bool,
123
+ parent_rel: str,
124
+ collect_every_level: bool,
125
+ ) -> list[_Arrival]:
126
+ """Every node reached from *start* by following *steps*, with the hops that reached it.
127
+
128
+ A node never appears twice on one branch (cycles stop). *exclude* (the focus node and
129
+ its ancestors) is never reached. From an ancestor, its structural parent edges are
130
+ never followed: they would only reach siblings.
131
+ """
132
+ frontier: list[tuple[str, tuple[_Hop, ...]]] = [(start, ())]
133
+ arrivals: list[_Arrival] = []
134
+ for index, step in enumerate(steps):
135
+ nxt: list[tuple[str, tuple[_Hop, ...]]] = []
136
+ for current, hops in frontier:
137
+ on_branch = {start, *(h.target for h in hops)}
138
+ for neighbour in graph.neighbours(current):
139
+ if inherited and index == 0 and neighbour.rel == parent_rel:
140
+ continue
141
+ other = graph.nodes[neighbour.other]
142
+ if neighbour.other in exclude or neighbour.other in on_branch:
143
+ continue
144
+ if not step.matches(neighbour.rel, neighbour.direction, other.kind):
145
+ continue
146
+ if not policy.visible(neighbour.rel, neighbour.direction, other.kind):
147
+ continue
148
+ hop = _Hop(current, neighbour.rel, neighbour.direction, neighbour.other)
149
+ nxt.append((neighbour.other, (*hops, hop)))
150
+ if len(nxt) + len(arrivals) > WALK_LIMIT:
151
+ break
152
+ frontier = nxt
153
+ if collect_every_level or index == len(steps) - 1:
154
+ arrivals.extend(_Arrival(key, start, hops) for key, hops in frontier)
155
+ if not frontier:
156
+ break
157
+ return arrivals
158
+
159
+
160
+ # ── items ────────────────────────────────────────────────────────────────
161
+
162
+
163
+ @dataclass
164
+ class _Entry:
165
+ node: GraphNode
166
+ rank: tuple[Any, ...]
167
+ view: str | None
168
+ path: str
169
+ detail: str
170
+ inherited_from: str | None
171
+ depth: int
172
+ whys: list[tuple[tuple[Any, ...], _Arrival, str]] = field(
173
+ default_factory=lambda: list[tuple[tuple[Any, ...], _Arrival, str]]()
174
+ )
175
+ blocking: list[dict[str, Any]] = field(default_factory=lambda: list[dict[str, Any]]())
176
+
177
+
178
+ @dataclass(frozen=True, slots=True)
179
+ class _Source:
180
+ """One thing walked: a view's path, an asked path, or the exploration."""
181
+
182
+ section: int
183
+ view: View | None
184
+ steps: tuple[Step, ...]
185
+ path_index: int
186
+ detail: str
187
+ inherit: bool
188
+ repeat: int
189
+ """For a single step, how many times to follow it; else 1."""
190
+
191
+
192
+ def _label(graph: Graph, schema: Schema, key: str) -> str:
193
+ node = graph.nodes[key]
194
+ return f"{node.kind} {key}"
195
+
196
+
197
+ def _why_payload(graph: Graph, schema: Schema, arrival: _Arrival, focus: str) -> dict[str, Any]:
198
+ inherited = arrival.start != focus
199
+ words: list[str] = []
200
+ if inherited:
201
+ words.append(_label(graph, schema, arrival.start))
202
+ for i, hop in enumerate(arrival.hops):
203
+ arrow = f"─{hop.rel}→" if hop.direction == OUT else f"←{hop.rel}─"
204
+ words.append(arrow)
205
+ if i < len(arrival.hops) - 1:
206
+ words.append(_label(graph, schema, hop.target))
207
+ if inherited:
208
+ words.append("(inherited)")
209
+ return {
210
+ "path": "/".join(f"{h.rel}:{h.direction}" for h in arrival.hops),
211
+ "from": arrival.start,
212
+ "inherited": inherited,
213
+ "hops": [
214
+ {"from": h.source, "rel": h.rel, "dir": h.direction, "to": h.target}
215
+ for h in arrival.hops
216
+ ],
217
+ "text": " ".join(words),
218
+ }
219
+
220
+
221
+ def _shorten(text: str, limit: int) -> str:
222
+ if len(text) <= limit:
223
+ return text
224
+ cut = text[:limit]
225
+ end = max(cut.rfind(". "), cut.rfind(".\n"))
226
+ if end >= limit // 2:
227
+ return cut[: end + 1]
228
+ return cut.rstrip() + "…"
229
+
230
+
231
+ def _scalar(value: object) -> bool:
232
+ return isinstance(value, (str, int, float, bool))
233
+
234
+
235
+ def _title(node: GraphNode, hints: ItemHints) -> str:
236
+ if hints.title_field:
237
+ value = node.spec.get(hints.title_field)
238
+ if isinstance(value, str) and value.strip():
239
+ return value.strip()
240
+ return node.name
241
+
242
+
243
+ def _item_payload(
244
+ entry: _Entry, level: str, graph: Graph, schema: Schema, focus: str
245
+ ) -> dict[str, Any]:
246
+ node = entry.node
247
+ hints = schema.hints(node.kind)
248
+ whys = sorted(entry.whys, key=lambda w: w[0])
249
+ payload: dict[str, Any] = {
250
+ "qualifiedName": node.key,
251
+ "kind": node.kind,
252
+ "title": _title(node, hints),
253
+ "view": entry.view,
254
+ "path": entry.path,
255
+ "depth": entry.depth,
256
+ "inheritedFrom": entry.inherited_from,
257
+ "detail": level,
258
+ "why": [_why_payload(graph, schema, arrival, focus) for _, arrival, _ in whys],
259
+ "blocking": (entry.blocking[0] if len(entry.blocking) == 1 else (entry.blocking or None)),
260
+ }
261
+ if level in (SUMMARY, FULL):
262
+ description = node.description or ""
263
+ payload["description"] = (
264
+ description if level == FULL else _shorten(description, DESCRIPTION_LIMIT)
265
+ )
266
+ payload["descriptionLength"] = len(description)
267
+ declared = schema.declared_fields(node.kind)
268
+ wanted = hints.summary_fields
269
+ fields: dict[str, Any] = {}
270
+ for name, field_type in declared:
271
+ if wanted is not None and name not in wanted:
272
+ continue
273
+ if wanted is None and field_type not in _SCALARS:
274
+ continue
275
+ value = node.spec.get(name)
276
+ if value is None or (hints.title_field and name == hints.title_field):
277
+ continue
278
+ if _scalar(value):
279
+ fields[name] = _shorten(value, FIELD_LIMIT) if isinstance(value, str) else value
280
+ elif level == FULL or wanted is not None:
281
+ fields[name] = value
282
+ payload["fields"] = fields
283
+ if level == FULL:
284
+ payload["spec"] = {
285
+ name: node.spec[name]
286
+ for name, _ in schema.declared_fields(node.kind)
287
+ if name in node.spec
288
+ }
289
+ payload["body"] = node.body
290
+ return payload
291
+
292
+
293
+ def _lower(level: str) -> list[str]:
294
+ """*level* and every level below it, highest first."""
295
+ return list(reversed(DETAIL_LEVELS[: DETAIL_LEVELS.index(level) + 1]))
296
+
297
+
298
+ # ── markdown sizes (shared with the renderer) ───────────────────────────
299
+
300
+
301
+ SizeOf = Callable[[dict[str, Any]], int]
302
+
303
+
304
+ def json_size(item: dict[str, Any]) -> int:
305
+ return estimate_tokens(json.dumps(item, separators=(",", ":"), default=str))
306
+
307
+
308
+ # ── the result ───────────────────────────────────────────────────────────
309
+
310
+
311
+ def _sources(
312
+ request: ContextRequest,
313
+ policy: Policy,
314
+ views: Mapping[str, View],
315
+ *,
316
+ always: Sequence[str],
317
+ depth: int,
318
+ detail_for: Callable[[str | None], str],
319
+ inherit: bool,
320
+ refused: list[dict[str, Any]],
321
+ notes: list[dict[str, Any]],
322
+ ) -> list[_Source]:
323
+ sources: list[_Source] = []
324
+
325
+ def add_view(view: View, section: int) -> None:
326
+ for index, steps in enumerate(view.paths):
327
+ hidden = next(
328
+ (by for by in (policy.step_hidden_by(s) for s in steps) if by is not None), None
329
+ )
330
+ if hidden is not None:
331
+ notes.append(
332
+ {
333
+ "view": view.name,
334
+ "path": path_text(steps),
335
+ "limitedBy": hidden,
336
+ "text": f"view {view.name!r} path {path_text(steps)!r} is hidden by {hidden}",
337
+ }
338
+ )
339
+ continue
340
+ if len(steps) > policy.max_depth:
341
+ notes.append(
342
+ {
343
+ "view": view.name,
344
+ "path": path_text(steps),
345
+ "limitedBy": policy.max_depth_by,
346
+ "text": f"view {view.name!r} path {path_text(steps)!r} is longer than "
347
+ f"max_depth {policy.max_depth} ({policy.max_depth_by})",
348
+ }
349
+ )
350
+ steps = steps[: policy.max_depth]
351
+ if not steps:
352
+ continue
353
+ sources.append(
354
+ _Source(
355
+ section=section,
356
+ view=view,
357
+ steps=steps,
358
+ path_index=index,
359
+ detail=detail_for(request.detail or view.detail),
360
+ inherit=view.inherit and inherit,
361
+ repeat=1,
362
+ )
363
+ )
364
+
365
+ if request.names_nothing:
366
+ for name in sorted(always, key=lambda n: -views[n].priority):
367
+ add_view(views[name], 0)
368
+ if not sources and not always:
369
+ sources.append(
370
+ _Source(3, None, (Step("*"),), 0, detail_for(request.detail), inherit, depth)
371
+ )
372
+ return sources
373
+ for name in sorted(request.views or (), key=lambda n: -views[n].priority):
374
+ add_view(views[name], 1)
375
+ for index, text in enumerate(request.paths or ()):
376
+ steps = parse_path(text)
377
+ hidden = next(
378
+ (by for by in (policy.step_hidden_by(s) for s in steps) if by is not None), None
379
+ )
380
+ if hidden is not None:
381
+ refused.append(
382
+ {"path": text, "limitedBy": hidden, "text": f"{text!r} is hidden by {hidden}"}
383
+ )
384
+ continue
385
+ if len(steps) > policy.max_depth:
386
+ refused.append(
387
+ {
388
+ "path": text,
389
+ "limitedBy": policy.max_depth_by,
390
+ "text": f"{text!r} is longer than max_depth {policy.max_depth} "
391
+ f"({policy.max_depth_by})",
392
+ }
393
+ )
394
+ continue
395
+ sources.append(
396
+ _Source(
397
+ 2,
398
+ None,
399
+ steps,
400
+ index,
401
+ detail_for(request.detail),
402
+ inherit,
403
+ depth if len(steps) == 1 else 1,
404
+ )
405
+ )
406
+ return sources
407
+
408
+
409
+ def _collect(
410
+ graph: Graph,
411
+ schema: Schema,
412
+ focus: str,
413
+ ancestors: Sequence[str],
414
+ policy: Policy,
415
+ sources: Sequence[_Source],
416
+ ) -> tuple[dict[str, _Entry], list[dict[str, Any]]]:
417
+ entries: dict[str, _Entry] = {}
418
+ exclude = frozenset({focus, *ancestors})
419
+ group_members: dict[tuple[str | None, str], set[str]] = {}
420
+ for source in sources:
421
+ starts = [focus, *(ancestors if source.inherit else ())]
422
+ steps = source.steps * source.repeat if source.repeat > 1 else source.steps
423
+ text = path_text(source.steps)
424
+ group = group_members.setdefault((source.view.name if source.view else None, text), set())
425
+ for distance, start in enumerate(starts):
426
+ arrivals = _walk(
427
+ graph,
428
+ start,
429
+ steps,
430
+ policy,
431
+ exclude=exclude,
432
+ inherited=start != focus,
433
+ parent_rel=schema.parent_rel,
434
+ collect_every_level=source.repeat > 1,
435
+ )
436
+ for arrival in arrivals:
437
+ node = graph.nodes[arrival.key]
438
+ hints = schema.hints(node.kind)
439
+ blocking = schema.blocking(node.kind, node.spec)
440
+ view_priority = source.view.priority if source.view else 0
441
+ blocking_rank = (
442
+ (0 if any(b.get("blocking") for b in blocking) else 1)
443
+ if source.view is not None and source.view.blocking_first
444
+ else 0
445
+ )
446
+ rank = (
447
+ source.section,
448
+ -view_priority,
449
+ source.view.name if source.view else "",
450
+ blocking_rank,
451
+ source.path_index,
452
+ distance,
453
+ len(arrival.hops),
454
+ -hints.priority,
455
+ node.key,
456
+ )
457
+ group.add(node.key)
458
+ entry = entries.get(node.key)
459
+ if entry is None or rank < entry.rank:
460
+ previous = entry.whys if entry is not None else []
461
+ entry = _Entry(
462
+ node=node,
463
+ rank=rank,
464
+ view=source.view.name if source.view else None,
465
+ path=text,
466
+ detail=source.detail,
467
+ inherited_from=None if start == focus else start,
468
+ depth=len(arrival.hops),
469
+ whys=previous,
470
+ blocking=blocking,
471
+ )
472
+ entries[node.key] = entry
473
+ if not any(w[1].hops == arrival.hops for w in entry.whys):
474
+ entry.whys.append((rank, arrival, text))
475
+ groups = [
476
+ {"view": view, "path": path, "count": len(members)}
477
+ for (view, path), members in group_members.items()
478
+ ]
479
+ return entries, groups
480
+
481
+
482
+ def build_result(
483
+ graph: Graph,
484
+ schema: Schema,
485
+ focus: str,
486
+ policy: Policy,
487
+ views: Mapping[str, View],
488
+ request: ContextRequest,
489
+ *,
490
+ size_of: SizeOf = json_size,
491
+ ) -> dict[str, Any]:
492
+ """The items *request* asks for around *focus*, budgeted, with why each is here."""
493
+ unknown = [name for name in request.views or () if name not in views]
494
+ if unknown:
495
+ raise ContextError(
496
+ "INVALID_INPUT",
497
+ f"{graph.nodes[focus].kind} has no view {unknown[0]!r}; it has: "
498
+ + (", ".join(sorted(views)) or "none"),
499
+ {"views": sorted(views)},
500
+ )
501
+ if request.detail is not None and request.detail not in DETAIL_LEVELS:
502
+ raise ContextError(
503
+ "INVALID_INPUT",
504
+ f"detail must be one of {', '.join(DETAIL_LEVELS)}; got {request.detail!r}",
505
+ )
506
+ clamped: list[dict[str, Any]] = []
507
+ refused: list[dict[str, Any]] = []
508
+ notes = list(policy.notes)
509
+ depth, note = policy.clamp_depth(request.depth)
510
+ clamped.extend([note] if note else [])
511
+ budget, note = policy.clamp_budget(request.budget)
512
+ clamped.extend([note] if note else [])
513
+ inherit, note = policy.clamp_inherit(request.inherit)
514
+ clamped.extend([note] if note else [])
515
+ requested_detail_note: list[dict[str, Any]] = []
516
+
517
+ def detail_for(wanted: str | None) -> str:
518
+ level, note = policy.clamp_detail(wanted, REFS)
519
+ if note and note not in requested_detail_note:
520
+ requested_detail_note.append(note)
521
+ return level
522
+
523
+ ancestors = graph.ancestors(focus, schema.parent_rel) if inherit else ()
524
+ always = [name for name in policy.always or () if name in views]
525
+ sources = _sources(
526
+ request,
527
+ policy,
528
+ views,
529
+ always=always,
530
+ depth=depth,
531
+ detail_for=detail_for,
532
+ inherit=inherit,
533
+ refused=refused,
534
+ notes=notes,
535
+ )
536
+ clamped.extend(requested_detail_note)
537
+ entries, groups = _collect(graph, schema, focus, ancestors, policy, sources)
538
+ ordered = sorted(entries.values(), key=lambda e: e.rank)
539
+
540
+ items: list[dict[str, Any]] = []
541
+ downgraded: list[dict[str, Any]] = []
542
+ cut: list[_Entry] = []
543
+ used = 0
544
+ for position, entry in enumerate(ordered):
545
+ placed = False
546
+ for level in _lower(entry.detail):
547
+ payload = _item_payload(entry, level, graph, schema, focus)
548
+ cost = size_of(payload)
549
+ if used + cost <= budget:
550
+ items.append(payload)
551
+ used += cost
552
+ if level != entry.detail:
553
+ downgraded.append(
554
+ {"qualifiedName": entry.node.key, "from": entry.detail, "to": level}
555
+ )
556
+ placed = True
557
+ break
558
+ if not placed:
559
+ cut = ordered[position:]
560
+ break
561
+
562
+ returned = {item["qualifiedName"] for item in items}
563
+ for group in groups:
564
+ group["returned"] = sum(
565
+ 1
566
+ for e in entries.values()
567
+ if e.node.key in returned and (e.view, e.path) == (group["view"], group["path"])
568
+ )
569
+ truncated: dict[str, Any] | None = None
570
+ if cut or downgraded:
571
+ by_group: dict[tuple[str | None, str], list[str]] = {}
572
+ for entry in cut:
573
+ by_group.setdefault((entry.view, entry.path), []).append(entry.node.key)
574
+ whole = sum(size_of(_item_payload(e, e.detail, graph, schema, focus)) for e in ordered)
575
+ truncated = {
576
+ "budget": budget,
577
+ "used": used,
578
+ "downgraded": downgraded,
579
+ "cut": [
580
+ {
581
+ "view": view,
582
+ "path": path,
583
+ "count": len(names),
584
+ "qualifiedNames": names[:CUT_NAMES],
585
+ }
586
+ for (view, path), names in by_group.items()
587
+ ],
588
+ "cutTotal": len(cut),
589
+ "hint": {"budget": min(max(whole, budget + 1), policy.budget_max)},
590
+ }
591
+ return {
592
+ "node": _node_payload(graph, schema, focus),
593
+ "request": request.as_payload(),
594
+ "effective": {
595
+ "views": list(dict.fromkeys(s.view.name for s in sources if s.view is not None)),
596
+ "paths": [path_text(s.steps) for s in sources if s.view is None],
597
+ "depth": depth,
598
+ "detail": detail_for(request.detail) if request.detail else None,
599
+ "budget": budget,
600
+ "inherit": inherit,
601
+ },
602
+ "items": items,
603
+ "groups": groups,
604
+ "truncated": truncated,
605
+ "used": used,
606
+ "dropped": {"unresolved": graph.dropped},
607
+ "policy": {
608
+ "layers": list(policy.layers),
609
+ "notes": notes,
610
+ "clamped": clamped,
611
+ "refused": refused,
612
+ },
613
+ }
614
+
615
+
616
+ def _node_payload(graph: Graph, schema: Schema, key: str) -> dict[str, Any]:
617
+ node = graph.nodes[key]
618
+ return {
619
+ "qualifiedName": key,
620
+ "kind": node.kind,
621
+ "kindLabel": schema.kind_label(node.kind),
622
+ "title": _title(node, schema.hints(node.kind)),
623
+ "description": _shorten(node.description or "", DESCRIPTION_LIMIT),
624
+ }
625
+
626
+
627
+ # ── the map ──────────────────────────────────────────────────────────────
628
+
629
+
630
+ def build_map(
631
+ graph: Graph,
632
+ schema: Schema,
633
+ focus: str,
634
+ policy: Policy,
635
+ views: Mapping[str, View],
636
+ *,
637
+ size_of: SizeOf = json_size,
638
+ ) -> dict[str, Any]:
639
+ """Every relationship around *focus*, what it inherits, its views and the options."""
640
+ ancestors = graph.ancestors(focus, schema.parent_rel) if policy.inheritance else ()
641
+ rows: list[dict[str, Any]] = []
642
+ hidden: dict[str, str] = {}
643
+ steps: set[str] = set()
644
+ for start in (focus, *ancestors):
645
+ grouped: dict[tuple[str, str], dict[str, int]] = {}
646
+ for neighbour in graph.neighbours(start):
647
+ if start != focus and neighbour.rel == schema.parent_rel:
648
+ continue
649
+ other = graph.nodes[neighbour.other]
650
+ if neighbour.other == focus:
651
+ continue
652
+ text = f"{neighbour.rel}:{neighbour.direction}"
653
+ by = policy.hidden_by(neighbour.rel, neighbour.direction, other.kind)
654
+ if by is not None:
655
+ hidden.setdefault(text, by)
656
+ continue
657
+ if start == focus:
658
+ steps.add(text)
659
+ kinds = grouped.setdefault((neighbour.rel, neighbour.direction), {})
660
+ kinds[other.kind] = kinds.get(other.kind, 0) + 1
661
+ for (rel, direction), kinds in sorted(grouped.items()):
662
+ row: dict[str, Any] = {
663
+ "path": f"{rel}:{direction}",
664
+ "rel": rel,
665
+ "dir": direction,
666
+ "description": schema.relationship_description(rel),
667
+ "count": sum(kinds.values()),
668
+ "kinds": dict(sorted(kinds.items())),
669
+ "from": "self" if start == focus else start,
670
+ }
671
+ if start != focus:
672
+ row["inherited"] = True
673
+ rows.append(row)
674
+ for rel, targets in schema.carried(graph.nodes[focus].kind):
675
+ text = f"{rel}:{OUT}"
676
+ visible = (
677
+ any(policy.visible(rel, OUT, kind) for kind in targets)
678
+ if targets
679
+ else policy.step_hidden_by(Step(rel, OUT)) is None
680
+ )
681
+ if visible:
682
+ steps.add(text)
683
+ for text in list(hidden):
684
+ if text in steps:
685
+ del hidden[text]
686
+
687
+ view_rows: list[dict[str, Any]] = []
688
+ always = [name for name in policy.always or () if name in views]
689
+ for name, view in sorted(views.items(), key=lambda kv: (-kv[1].priority, kv[0])):
690
+ counted = build_result(
691
+ graph,
692
+ schema,
693
+ focus,
694
+ policy,
695
+ views,
696
+ ContextRequest(views=(name,), detail=REFS, budget=policy.budget_max),
697
+ size_of=lambda _item: 0,
698
+ )
699
+ view_rows.append(
700
+ {
701
+ "name": name,
702
+ "title": view.title,
703
+ "description": view.description,
704
+ "paths": view.path_texts,
705
+ "inherit": view.inherit,
706
+ "detail": view.detail,
707
+ "always": name in always,
708
+ "count": len(counted["items"]),
709
+ }
710
+ )
711
+ always_result = (
712
+ build_result(graph, schema, focus, policy, views, ContextRequest(), size_of=size_of)
713
+ if always
714
+ else None
715
+ )
716
+ address = focus
717
+ suggestions = [
718
+ f"taxonomy_context(node={address!r}, views=[{name!r}])"
719
+ for name in sorted(views)
720
+ if name not in always
721
+ ]
722
+ present = [r["path"] for r in rows if r["from"] == "self" and r["rel"] != schema.parent_rel]
723
+ if present or steps:
724
+ first = present[0] if present else sorted(steps)[0]
725
+ suggestions.append(
726
+ f"taxonomy_context(node={address!r}, paths=[{first!r}], detail='summary')"
727
+ )
728
+ return {
729
+ "node": _node_payload(graph, schema, focus),
730
+ "ancestors": [
731
+ {
732
+ "qualifiedName": key,
733
+ "kind": graph.nodes[key].kind,
734
+ "title": _title(graph.nodes[key], schema.hints(graph.nodes[key].kind)),
735
+ }
736
+ for key in ancestors
737
+ ],
738
+ "relationships": rows,
739
+ "hiddenByPolicy": [{"path": text, "limitedBy": by} for text, by in sorted(hidden.items())],
740
+ "views": view_rows,
741
+ "always": always_result,
742
+ "options": {
743
+ "steps": sorted(steps),
744
+ "maxDepth": policy.max_depth,
745
+ "detail": list(policy.detail),
746
+ "defaultDetail": policy.default_detail
747
+ if policy.default_detail in policy.detail
748
+ else REFS,
749
+ "budget": {
750
+ "default": min(policy.default_budget, policy.budget_max),
751
+ "max": policy.budget_max,
752
+ },
753
+ "inheritance": policy.inheritance,
754
+ },
755
+ "policy": {
756
+ "layers": list(policy.layers),
757
+ "notes": list(policy.notes),
758
+ "clamped": [],
759
+ "refused": [],
760
+ },
761
+ "next": suggestions,
762
+ }