dsh-plugin-xujie-memory 0.5.0 → 0.7.0

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.
Files changed (84) hide show
  1. package/ACCEPTANCE.md +37 -0
  2. package/BUILD-PROVENANCE.json +53 -0
  3. package/CHANGELOG.md +40 -1
  4. package/LICENSE +21 -21
  5. package/README.md +67 -17
  6. package/README_EN.md +54 -8
  7. package/cordis.patch.yml +49 -49
  8. package/lib/client.js +17219 -0
  9. package/lib/controller.js +307 -0
  10. package/lib/index.js +200 -14
  11. package/lib/typert.host.d.ts +3 -0
  12. package/lib/typert.host.js +15104 -0
  13. package/lib/typert.remote-client.d.ts +40 -0
  14. package/lib/typert.remote-client.d.ts.map +1 -0
  15. package/lib/typert.remote-client.js +15093 -0
  16. package/lib/types.d.ts +13 -0
  17. package/lib/types.js +1 -0
  18. package/package.json +49 -5
  19. package/python/specs/characters/aloof.json +14 -14
  20. package/python/specs/characters/gentle.json +14 -14
  21. package/python/specs/characters/tsundere.json +14 -14
  22. package/python/specs/debt.v1.json +31 -31
  23. package/python/specs/nickname.v1.json +16 -16
  24. package/python/specs/preference.v1.json +16 -16
  25. package/python/xujie_memory/__init__.py +123 -123
  26. package/python/xujie_memory/admission.py +211 -211
  27. package/python/xujie_memory/audit.py +377 -377
  28. package/python/xujie_memory/benchmark.py +281 -281
  29. package/python/xujie_memory/character.py +199 -199
  30. package/python/xujie_memory/console.py +41 -41
  31. package/python/xujie_memory/consolidate.py +356 -356
  32. package/python/xujie_memory/due.py +121 -121
  33. package/python/xujie_memory/emotion.py +573 -573
  34. package/python/xujie_memory/engine.py +1333 -1312
  35. package/python/xujie_memory/errors.py +98 -98
  36. package/python/xujie_memory/importance.py +104 -104
  37. package/python/xujie_memory/judge.py +350 -350
  38. package/python/xujie_memory/management.py +203 -0
  39. package/python/xujie_memory/memory.py +71 -10
  40. package/python/xujie_memory/orchestrator.py +690 -666
  41. package/python/xujie_memory/phrasing.py +292 -292
  42. package/python/xujie_memory/privacy.py +105 -0
  43. package/python/xujie_memory/schema.sql +492 -480
  44. package/python/xujie_memory/server.py +76 -0
  45. package/python/xujie_memory/specs.py +170 -170
  46. package/python/xujie_memory/summary.py +238 -238
  47. package/scripts/build.mjs +19 -0
  48. package/scripts/prepack.mjs +9 -0
  49. package/scripts/test-all.mjs +9 -0
  50. package/scripts/test-node.mjs +10 -0
  51. package/src/client/MemoryPanel.tsx +536 -0
  52. package/src/client/NeuralField.tsx +88 -0
  53. package/src/client/css.d.ts +4 -0
  54. package/src/client/icons.tsx +28 -0
  55. package/src/client/index.tsx +59 -0
  56. package/src/client/memory-adapter.ts +67 -0
  57. package/src/client/model.ts +79 -0
  58. package/src/client/neural/engine.ts +999 -0
  59. package/src/client/neural/morphology.ts +219 -0
  60. package/src/client/panel.css +337 -0
  61. package/src/client/types.ts +6 -0
  62. package/src/controller.ts +88 -0
  63. package/src/generated/typert.host.js +449 -0
  64. package/src/generated/typert.remote-client.js +438 -0
  65. package/src/index.ts +2 -0
  66. package/src/types.ts +13 -0
  67. package/test/cordis-fixture.mjs +5 -0
  68. package/test/management_regression.py +187 -0
  69. package/test/node-regression.entry.mjs +52 -0
  70. package/vendor/DSH-LICENSE.txt +21 -0
  71. package/vendor/THIRD-PARTY.md +6 -0
  72. package/vendor/dsh-protocol/index.ts +484 -0
  73. package/vendor/dsh-protocol/json-value.ts +53 -0
  74. package/vendor/dsh-protocol/owned-value.ts +39 -0
  75. package/vendor/dsh-protocol/principal.ts +17 -0
  76. package/vendor/dsh-protocol/remote-access.ts +38 -0
  77. package/vendor/dsh-protocol/remote-error.ts +49 -0
  78. package/vendor/dsh-protocol/remote-info.ts +161 -0
  79. package/vendor/dsh-protocol/types.ts +939 -0
  80. package/vendor/licenses/jridgewell-gen-mapping-MIT.txt +19 -0
  81. package/vendor/licenses/jridgewell-resolve-uri-MIT.txt +19 -0
  82. package/vendor/licenses/jridgewell-sourcemap-codec-MIT.txt +19 -0
  83. package/vendor/licenses/jridgewell-trace-mapping-MIT.txt +19 -0
  84. package/vendor/licenses/zod-MIT.txt +21 -0
@@ -53,6 +53,7 @@ from . import __version__
53
53
  from .encoder import RuleEncoder
54
54
  from .engine import Engine, SpecRegistry
55
55
  from .memory import Memory
56
+ from .management import Management, ManagementError
56
57
  from .orchestrator import Orchestrator, TurnBudget
57
58
  from .profile import ProfileStore
58
59
 
@@ -95,6 +96,8 @@ class MemoryService:
95
96
  self.db_path = str(db_path)
96
97
  self.engine = Engine(Path(db_path), SpecRegistry.from_dir(Path(specs_dir)))
97
98
  self.profiles = ProfileStore(self.engine)
99
+ from .privacy import repair_legacy
100
+ repair_legacy(self.engine, self.profiles)
98
101
  self.orchestrator = Orchestrator(
99
102
  self.engine,
100
103
  profiles=self.profiles,
@@ -106,6 +109,9 @@ class MemoryService:
106
109
  # `INSERT OR IGNORE` 兜住了,这里只是避免无谓的重复计算。
107
110
  self._scopes: dict[str, str] = {}
108
111
  self._lock = threading.Lock()
112
+ # Serialize HTTP writes with reads of dependent projections. In
113
+ # particular, a /turn cannot republish a half-edited graph.
114
+ self.request_lock = threading.RLock()
109
115
 
110
116
  # ------------------------------------------------------------ scope
111
117
  def scope_of(self, session: str, user: str = "user") -> str:
@@ -212,6 +218,19 @@ class MemoryService:
212
218
  return {"ok": True, "session": session,
213
219
  "points": Memory(self.engine).curve(scope, trace_id, points)}
214
220
 
221
+ def memory_forget(self, session: str, trace_id: str, *,
222
+ user: str = "user") -> dict[str, Any]:
223
+ """删一条记忆。主 AI 主动删时用;用户说"忘掉这条"也走这里。"""
224
+ scope = self.scope_of(session, user)
225
+ return {"ok": True, "session": session,
226
+ **Memory(self.engine).forget(scope, trace_id)}
227
+
228
+ def memory_stats(self, session: str, *, user: str = "user") -> dict[str, Any]:
229
+ """库里有多少条记忆、多少条被删过、多少条锚住——透明化用。"""
230
+ scope = self.scope_of(session, user)
231
+ return {"ok": True, "session": session,
232
+ **Memory(self.engine).stats(scope)}
233
+
215
234
  def close(self) -> None:
216
235
  self.engine.close()
217
236
 
@@ -329,7 +348,12 @@ class _Handler(BaseHTTPRequestHandler):
329
348
  self._json(400, {"ok": False, "error": f"bad_json: {exc}"})
330
349
  return
331
350
 
351
+ self.service.request_lock.acquire()
332
352
  try:
353
+ if route.startswith("/manage/"):
354
+ payload = self._manage(route, body)
355
+ self._json(200, payload)
356
+ return
333
357
  session = _str_field(body, "session")
334
358
  if not session:
335
359
  self._json(400, {"ok": False, "error": "missing_session"})
@@ -366,6 +390,24 @@ class _Handler(BaseHTTPRequestHandler):
366
390
  user=user,
367
391
  limit=int(_num_field(body, "limit", 5, 1, 50)),
368
392
  )
393
+ elif route == "/memory/stats":
394
+ payload = self.service.memory_stats(session, user=user)
395
+ elif route == "/memory/forget":
396
+ trace_id = _str_field(body, "trace_id")
397
+ if not trace_id:
398
+ raise BadRequest("missing_trace_id")
399
+ try:
400
+ payload = self.service.memory_forget(session, trace_id, user=user)
401
+ except ValueError as exc:
402
+ # 库里没有这条 / 不属于这个 scope:调用方参数问题,不是服务端崩了。
403
+ raise BadRequest(str(exc)) from exc
404
+ elif route == "/memory/edit":
405
+ payload = Management(self.service).edit(
406
+ self.service.scope_of(session, user),
407
+ _str_field(body, "trace_id"),
408
+ _str_field(body, "content", limit=MAX_TEXT_CHARS),
409
+ _str_field(body, "expected_hash"),
410
+ )
369
411
  elif route in ("/memory/mark", "/memory/unmark", "/memory/curve"):
370
412
  trace_id = _str_field(body, "trace_id")
371
413
  if not trace_id:
@@ -386,6 +428,12 @@ class _Handler(BaseHTTPRequestHandler):
386
428
  else:
387
429
  self._json(404, {"ok": False, "error": "not_found"})
388
430
  return
431
+ except ManagementError as exc:
432
+ status = 409 if exc.code == "version_conflict" else (
433
+ 404 if exc.code.endswith("_not_found") else 400
434
+ )
435
+ self._json(status, {"ok": False, "error": exc.code})
436
+ return
389
437
  except BadRequest as exc:
390
438
  self._json(400, {"ok": False, "error": "bad_request", "detail": str(exc)})
391
439
  return
@@ -398,9 +446,37 @@ class _Handler(BaseHTTPRequestHandler):
398
446
  self._json(500, {"ok": False, "error": type(exc).__name__,
399
447
  "detail": str(exc)[:300]})
400
448
  return
449
+ finally:
450
+ self.service.request_lock.release()
401
451
 
402
452
  self._json(200, payload)
403
453
 
454
+ def _manage(self, route: str, body: dict[str, Any]) -> dict[str, Any]:
455
+ manager = Management(self.service)
456
+ if route == "/manage/catalog":
457
+ return manager.catalog()
458
+ scope = _str_field(body, "scope_id")
459
+ if not scope:
460
+ raise BadRequest("missing_scope_id")
461
+ if route == "/manage/snapshot":
462
+ return manager.snapshot(scope, int(_num_field(body, "limit", 250, 1, 500)))
463
+ if route == "/manage/search":
464
+ return manager.search(
465
+ scope, _str_field(body, "query", limit=MAX_TEXT_CHARS),
466
+ int(_num_field(body, "limit", 30, 1, 50)),
467
+ )
468
+ trace = _str_field(body, "trace_id")
469
+ if route == "/manage/detail":
470
+ return manager.detail(scope, trace)
471
+ expected = _str_field(body, "expected_hash")
472
+ if route == "/manage/edit":
473
+ return manager.edit(
474
+ scope, trace, _str_field(body, "content", limit=MAX_TEXT_CHARS), expected
475
+ )
476
+ if route == "/manage/forget":
477
+ return manager.forget(scope, trace, expected)
478
+ raise ManagementError("route_not_found")
479
+
404
480
  # ---------------------------------------------------------- 工具
405
481
  def _read_json(self) -> dict[str, Any]:
406
482
  try:
@@ -1,170 +1,170 @@
1
- """关系规格(RelationSpec)。
2
-
3
- 设计文档 7.1 的理由:哪些关系可自动接受、负值是否允许、谁能写,
4
- 不能由模型临时决定,也不应散落在代码里。每种关系用一份版本化规格声明,
5
- 内核只执行规格,不自行推断。
6
- """
7
-
8
- from __future__ import annotations
9
-
10
- import json
11
- from dataclasses import dataclass, field
12
- from pathlib import Path
13
- from typing import Any
14
-
15
- from .errors import SpecNotFound
16
-
17
- RISK_TIERS = ("R0", "R1", "R2", "R3")
18
- ADMISSION_MODES = ("shadow", "assisted", "auto")
19
- EFFECT_KINDS = ("set", "delta", "invalidate")
20
- MODALITIES = (
21
- "asserted",
22
- # 请求 / 祈使。「给我免三碗」不是在陈述事实,也不是假设——
23
- # 说话人是真的在要求这件事。缺了这一格,它会被判成 hypothetical
24
- # 然后 record_only,用户明确要求的事反而不记(实测踩到)。
25
- "request",
26
- "hypothetical",
27
- "conditional",
28
- "quoted",
29
- "denied",
30
- "planned",
31
- "joking",
32
- "ironic",
33
- "in_world",
34
- "unclear",
35
- )
36
-
37
-
38
- @dataclass(frozen=True)
39
- class RelationSpec:
40
- relation: str
41
- version: str
42
- risk_tier: str
43
- required_slots: tuple[str, ...]
44
- allowed_modalities: tuple[str, ...]
45
- writers: tuple[str, ...]
46
- effect_kind: str
47
- admission_mode: str = "shadow"
48
- value_type: str = "integer" # integer / number / string / enum
49
- # XM-12: unit 只用于展示,不参与 state_key,也不参与校验。
50
- # 编码器给出与规格不符的单位时不会报错——这是有意的取舍,见 ISSUES。
51
- # 真要校验,应判 disputed 而不是拒绝(拒绝会丢掉证据)。
52
- unit: str | None = None
53
- allow_negative: bool = False
54
- enum_values: tuple[str, ...] = ()
55
- confirmation: str = "none" # none / implicit / explicit / authority
56
- validity: str = "permanent" # permanent / ttl / reconfirm
57
- ttl_seconds: int | None = None
58
- conflict_policy: str = "dispute" # dispute / reject / last_write_wins
59
- conservation: str | None = None # None / "repayment":追踪累计偿付量
60
- feeds: tuple[str, ...] = ()
61
- derived_from: tuple[str, ...] = ()
62
- note: str = ""
63
-
64
- # ------------------------------------------------------------- 自检
65
- def validate(self) -> None:
66
- if self.risk_tier not in RISK_TIERS:
67
- raise ValueError(f"{self.relation}: bad risk_tier {self.risk_tier!r}")
68
- if self.admission_mode not in ADMISSION_MODES:
69
- raise ValueError(f"{self.relation}: bad admission_mode {self.admission_mode!r}")
70
- if self.effect_kind not in EFFECT_KINDS:
71
- raise ValueError(f"{self.relation}: bad effect_kind {self.effect_kind!r}")
72
- for m in self.allowed_modalities:
73
- if m not in MODALITIES:
74
- raise ValueError(f"{self.relation}: unknown modality {m!r}")
75
- if not self.required_slots:
76
- raise ValueError(f"{self.relation}: required_slots must not be empty")
77
- # R3 永远不允许自动提交(设计文档 7.3 / 15.5)
78
- if self.risk_tier == "R3" and self.admission_mode == "auto":
79
- raise ValueError(
80
- f"{self.relation}: R3 relation must not be 'auto' "
81
- "(permissions/deletion go through the control channel only)"
82
- )
83
- if self.value_type == "enum" and not self.enum_values:
84
- raise ValueError(f"{self.relation}: enum value_type requires enum_values")
85
- if self.conservation not in (None, "repayment"):
86
- raise ValueError(f"{self.relation}: unknown conservation {self.conservation!r}")
87
-
88
- @property
89
- def spec_version(self) -> str:
90
- return f"{self.relation}@{self.version}"
91
-
92
-
93
- @dataclass
94
- class SpecRegistry:
95
- _by_relation: dict[str, RelationSpec] = field(default_factory=dict)
96
-
97
- # ------------------------------------------------------------- 加载
98
- @classmethod
99
- def from_dir(cls, path: str | Path) -> "SpecRegistry":
100
- reg = cls()
101
- for f in sorted(Path(path).glob("*.json")):
102
- reg.add(RelationSpec(**json.loads(f.read_text(encoding="utf-8"))))
103
- return reg
104
-
105
- @classmethod
106
- def from_specs(cls, specs: list[RelationSpec]) -> "SpecRegistry":
107
- reg = cls()
108
- for s in specs:
109
- reg.add(s)
110
- return reg
111
-
112
- def add(self, spec: RelationSpec) -> None:
113
- spec.validate()
114
- if spec.relation in self._by_relation:
115
- raise ValueError(f"duplicate relation spec: {spec.relation}")
116
- self._by_relation[spec.relation] = spec
117
-
118
- # ------------------------------------------------------------- 查询
119
- def get(self, relation: str) -> RelationSpec:
120
- try:
121
- return self._by_relation[relation]
122
- except KeyError:
123
- raise SpecNotFound(f"no spec registered for relation {relation!r}") from None
124
-
125
- def all(self) -> list[RelationSpec]:
126
- return list(self._by_relation.values())
127
-
128
- # ------------------------------------------------------------- 依赖图
129
- def dependency_edges(self) -> list[tuple[str, str]]:
130
- """返回 (input_relation, derived_relation) 边列表,用于环检测。"""
131
- edges: list[tuple[str, str]] = []
132
- for spec in self._by_relation.values():
133
- for src in spec.derived_from:
134
- edges.append((src, spec.relation))
135
- return edges
136
-
137
- def assert_acyclic(self) -> None:
138
- """依赖图必须为 DAG(设计文档 9.5)。"""
139
- from .errors import DependencyCycle
140
-
141
- graph: dict[str, set[str]] = {}
142
- for src, dst in self.dependency_edges():
143
- graph.setdefault(src, set()).add(dst)
144
-
145
- WHITE, GREY, BLACK = 0, 1, 2
146
- color: dict[str, int] = {}
147
-
148
- def visit(node: str, stack: list[str]) -> None:
149
- color[node] = GREY
150
- stack.append(node)
151
- for nxt in graph.get(node, ()):
152
- c = color.get(nxt, WHITE)
153
- if c == GREY:
154
- cycle = " -> ".join(stack[stack.index(nxt):] + [nxt])
155
- raise DependencyCycle(f"dependency cycle: {cycle}")
156
- if c == WHITE:
157
- visit(nxt, stack)
158
- stack.pop()
159
- color[node] = BLACK
160
-
161
- for node in list(graph):
162
- if color.get(node, WHITE) == WHITE:
163
- visit(node, [])
164
-
165
-
166
- def slot_value(spec: RelationSpec, slots: dict[str, Any], name: str) -> Any:
167
- """取槽位;缺失时抛错,不做默认值填充(I5)。"""
168
- if name not in slots:
169
- raise KeyError(f"missing required slot {name!r} for relation {spec.relation!r}")
170
- return slots[name]
1
+ """关系规格(RelationSpec)。
2
+
3
+ 设计文档 7.1 的理由:哪些关系可自动接受、负值是否允许、谁能写,
4
+ 不能由模型临时决定,也不应散落在代码里。每种关系用一份版本化规格声明,
5
+ 内核只执行规格,不自行推断。
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from dataclasses import dataclass, field
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from .errors import SpecNotFound
16
+
17
+ RISK_TIERS = ("R0", "R1", "R2", "R3")
18
+ ADMISSION_MODES = ("shadow", "assisted", "auto")
19
+ EFFECT_KINDS = ("set", "delta", "invalidate")
20
+ MODALITIES = (
21
+ "asserted",
22
+ # 请求 / 祈使。「给我免三碗」不是在陈述事实,也不是假设——
23
+ # 说话人是真的在要求这件事。缺了这一格,它会被判成 hypothetical
24
+ # 然后 record_only,用户明确要求的事反而不记(实测踩到)。
25
+ "request",
26
+ "hypothetical",
27
+ "conditional",
28
+ "quoted",
29
+ "denied",
30
+ "planned",
31
+ "joking",
32
+ "ironic",
33
+ "in_world",
34
+ "unclear",
35
+ )
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class RelationSpec:
40
+ relation: str
41
+ version: str
42
+ risk_tier: str
43
+ required_slots: tuple[str, ...]
44
+ allowed_modalities: tuple[str, ...]
45
+ writers: tuple[str, ...]
46
+ effect_kind: str
47
+ admission_mode: str = "shadow"
48
+ value_type: str = "integer" # integer / number / string / enum
49
+ # XM-12: unit 只用于展示,不参与 state_key,也不参与校验。
50
+ # 编码器给出与规格不符的单位时不会报错——这是有意的取舍,见 ISSUES。
51
+ # 真要校验,应判 disputed 而不是拒绝(拒绝会丢掉证据)。
52
+ unit: str | None = None
53
+ allow_negative: bool = False
54
+ enum_values: tuple[str, ...] = ()
55
+ confirmation: str = "none" # none / implicit / explicit / authority
56
+ validity: str = "permanent" # permanent / ttl / reconfirm
57
+ ttl_seconds: int | None = None
58
+ conflict_policy: str = "dispute" # dispute / reject / last_write_wins
59
+ conservation: str | None = None # None / "repayment":追踪累计偿付量
60
+ feeds: tuple[str, ...] = ()
61
+ derived_from: tuple[str, ...] = ()
62
+ note: str = ""
63
+
64
+ # ------------------------------------------------------------- 自检
65
+ def validate(self) -> None:
66
+ if self.risk_tier not in RISK_TIERS:
67
+ raise ValueError(f"{self.relation}: bad risk_tier {self.risk_tier!r}")
68
+ if self.admission_mode not in ADMISSION_MODES:
69
+ raise ValueError(f"{self.relation}: bad admission_mode {self.admission_mode!r}")
70
+ if self.effect_kind not in EFFECT_KINDS:
71
+ raise ValueError(f"{self.relation}: bad effect_kind {self.effect_kind!r}")
72
+ for m in self.allowed_modalities:
73
+ if m not in MODALITIES:
74
+ raise ValueError(f"{self.relation}: unknown modality {m!r}")
75
+ if not self.required_slots:
76
+ raise ValueError(f"{self.relation}: required_slots must not be empty")
77
+ # R3 永远不允许自动提交(设计文档 7.3 / 15.5)
78
+ if self.risk_tier == "R3" and self.admission_mode == "auto":
79
+ raise ValueError(
80
+ f"{self.relation}: R3 relation must not be 'auto' "
81
+ "(permissions/deletion go through the control channel only)"
82
+ )
83
+ if self.value_type == "enum" and not self.enum_values:
84
+ raise ValueError(f"{self.relation}: enum value_type requires enum_values")
85
+ if self.conservation not in (None, "repayment"):
86
+ raise ValueError(f"{self.relation}: unknown conservation {self.conservation!r}")
87
+
88
+ @property
89
+ def spec_version(self) -> str:
90
+ return f"{self.relation}@{self.version}"
91
+
92
+
93
+ @dataclass
94
+ class SpecRegistry:
95
+ _by_relation: dict[str, RelationSpec] = field(default_factory=dict)
96
+
97
+ # ------------------------------------------------------------- 加载
98
+ @classmethod
99
+ def from_dir(cls, path: str | Path) -> "SpecRegistry":
100
+ reg = cls()
101
+ for f in sorted(Path(path).glob("*.json")):
102
+ reg.add(RelationSpec(**json.loads(f.read_text(encoding="utf-8"))))
103
+ return reg
104
+
105
+ @classmethod
106
+ def from_specs(cls, specs: list[RelationSpec]) -> "SpecRegistry":
107
+ reg = cls()
108
+ for s in specs:
109
+ reg.add(s)
110
+ return reg
111
+
112
+ def add(self, spec: RelationSpec) -> None:
113
+ spec.validate()
114
+ if spec.relation in self._by_relation:
115
+ raise ValueError(f"duplicate relation spec: {spec.relation}")
116
+ self._by_relation[spec.relation] = spec
117
+
118
+ # ------------------------------------------------------------- 查询
119
+ def get(self, relation: str) -> RelationSpec:
120
+ try:
121
+ return self._by_relation[relation]
122
+ except KeyError:
123
+ raise SpecNotFound(f"no spec registered for relation {relation!r}") from None
124
+
125
+ def all(self) -> list[RelationSpec]:
126
+ return list(self._by_relation.values())
127
+
128
+ # ------------------------------------------------------------- 依赖图
129
+ def dependency_edges(self) -> list[tuple[str, str]]:
130
+ """返回 (input_relation, derived_relation) 边列表,用于环检测。"""
131
+ edges: list[tuple[str, str]] = []
132
+ for spec in self._by_relation.values():
133
+ for src in spec.derived_from:
134
+ edges.append((src, spec.relation))
135
+ return edges
136
+
137
+ def assert_acyclic(self) -> None:
138
+ """依赖图必须为 DAG(设计文档 9.5)。"""
139
+ from .errors import DependencyCycle
140
+
141
+ graph: dict[str, set[str]] = {}
142
+ for src, dst in self.dependency_edges():
143
+ graph.setdefault(src, set()).add(dst)
144
+
145
+ WHITE, GREY, BLACK = 0, 1, 2
146
+ color: dict[str, int] = {}
147
+
148
+ def visit(node: str, stack: list[str]) -> None:
149
+ color[node] = GREY
150
+ stack.append(node)
151
+ for nxt in graph.get(node, ()):
152
+ c = color.get(nxt, WHITE)
153
+ if c == GREY:
154
+ cycle = " -> ".join(stack[stack.index(nxt):] + [nxt])
155
+ raise DependencyCycle(f"dependency cycle: {cycle}")
156
+ if c == WHITE:
157
+ visit(nxt, stack)
158
+ stack.pop()
159
+ color[node] = BLACK
160
+
161
+ for node in list(graph):
162
+ if color.get(node, WHITE) == WHITE:
163
+ visit(node, [])
164
+
165
+
166
+ def slot_value(spec: RelationSpec, slots: dict[str, Any], name: str) -> Any:
167
+ """取槽位;缺失时抛错,不做默认值填充(I5)。"""
168
+ if name not in slots:
169
+ raise KeyError(f"missing required slot {name!r} for relation {spec.relation!r}")
170
+ return slots[name]