rememberstack 0.1.0__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.
Files changed (186) hide show
  1. rememberstack/__init__.py +9 -0
  2. rememberstack/adapters/__init__.py +42 -0
  3. rememberstack/adapters/codex_writer.py +221 -0
  4. rememberstack/adapters/markitdown_converter.py +42 -0
  5. rememberstack/adapters/openrouter.py +136 -0
  6. rememberstack/adapters/selfhost/__init__.py +54 -0
  7. rememberstack/adapters/selfhost/forget.py +66 -0
  8. rememberstack/adapters/selfhost/git.py +374 -0
  9. rememberstack/adapters/selfhost/lance.py +328 -0
  10. rememberstack/adapters/selfhost/minio.py +279 -0
  11. rememberstack/adapters/selfhost/mounts.py +249 -0
  12. rememberstack/adapters/selfhost/object_store.py +130 -0
  13. rememberstack/adapters/selfhost/projection.py +80 -0
  14. rememberstack/adapters/selfhost/queue.py +137 -0
  15. rememberstack/adapters/selfhost/telemetry.py +45 -0
  16. rememberstack/adapters/selfhost/watcher.py +70 -0
  17. rememberstack/adapters/testing/__init__.py +15 -0
  18. rememberstack/adapters/testing/cost_meter.py +13 -0
  19. rememberstack/adapters/testing/model_provider.py +83 -0
  20. rememberstack/adapters/testing/queue.py +43 -0
  21. rememberstack/adapters/testing/telemetry.py +22 -0
  22. rememberstack/client.py +19 -0
  23. rememberstack/core/__init__.py +127 -0
  24. rememberstack/core/blockizer.py +189 -0
  25. rememberstack/core/chunker.py +216 -0
  26. rememberstack/core/consumption_skill.py +275 -0
  27. rememberstack/core/conversion.py +76 -0
  28. rememberstack/core/core_manifest.py +598 -0
  29. rememberstack/core/extension_packs.py +124 -0
  30. rememberstack/core/forget.py +17 -0
  31. rememberstack/core/knowledge_authored.py +276 -0
  32. rememberstack/core/knowledge_compile.py +215 -0
  33. rememberstack/core/knowledge_fact_sheet.py +210 -0
  34. rememberstack/core/knowledge_hashing.py +68 -0
  35. rememberstack/core/knowledge_planner.py +64 -0
  36. rememberstack/core/knowledge_writer.py +175 -0
  37. rememberstack/core/ranking.py +200 -0
  38. rememberstack/core/recipe_linter.py +149 -0
  39. rememberstack/core/section_snap.py +209 -0
  40. rememberstack/core/storage_routing.py +27 -0
  41. rememberstack/eval/__init__.py +53 -0
  42. rememberstack/eval/consumption.py +141 -0
  43. rememberstack/eval/contradiction.py +184 -0
  44. rememberstack/eval/harness.py +136 -0
  45. rememberstack/eval/lifecycle.py +400 -0
  46. rememberstack/eval/operational_scale.py +49 -0
  47. rememberstack/eval/resolution.py +255 -0
  48. rememberstack/eval/retrieval_spikes.py +50 -0
  49. rememberstack/eval/skeleton.py +231 -0
  50. rememberstack/llm/__init__.py +1 -0
  51. rememberstack/model/__init__.py +589 -0
  52. rememberstack/model/adjudication.py +100 -0
  53. rememberstack/model/auth.py +27 -0
  54. rememberstack/model/blocks.py +30 -0
  55. rememberstack/model/chunks.py +190 -0
  56. rememberstack/model/claims.py +162 -0
  57. rememberstack/model/client.py +98 -0
  58. rememberstack/model/clustering.py +54 -0
  59. rememberstack/model/component_version.py +124 -0
  60. rememberstack/model/consumption.py +88 -0
  61. rememberstack/model/conversion.py +31 -0
  62. rememberstack/model/deployment.py +53 -0
  63. rememberstack/model/documents.py +168 -0
  64. rememberstack/model/envelope.py +513 -0
  65. rememberstack/model/evaluation.py +72 -0
  66. rememberstack/model/forget.py +143 -0
  67. rememberstack/model/git.py +13 -0
  68. rememberstack/model/knowledge.py +840 -0
  69. rememberstack/model/knowledge_authored.py +325 -0
  70. rememberstack/model/knowledge_planner.py +431 -0
  71. rememberstack/model/lifecycle.py +42 -0
  72. rememberstack/model/model_provider.py +78 -0
  73. rememberstack/model/mounts.py +24 -0
  74. rememberstack/model/object_store.py +21 -0
  75. rememberstack/model/operational_scale.py +59 -0
  76. rememberstack/model/operations.py +153 -0
  77. rememberstack/model/processing.py +228 -0
  78. rememberstack/model/queue.py +73 -0
  79. rememberstack/model/recipes.py +83 -0
  80. rememberstack/model/relations.py +79 -0
  81. rememberstack/model/resolution.py +83 -0
  82. rememberstack/model/retrieval_spikes.py +62 -0
  83. rememberstack/model/sections.py +120 -0
  84. rememberstack/model/telemetry.py +30 -0
  85. rememberstack/ports/__init__.py +29 -0
  86. rememberstack/ports/auth.py +16 -0
  87. rememberstack/ports/connector.py +23 -0
  88. rememberstack/ports/cost_meter.py +17 -0
  89. rememberstack/ports/forget.py +20 -0
  90. rememberstack/ports/git.py +20 -0
  91. rememberstack/ports/model_provider.py +28 -0
  92. rememberstack/ports/mounts.py +16 -0
  93. rememberstack/ports/object_store.py +27 -0
  94. rememberstack/ports/p1_index.py +92 -0
  95. rememberstack/ports/purge.py +93 -0
  96. rememberstack/ports/queue.py +23 -0
  97. rememberstack/ports/telemetry.py +21 -0
  98. rememberstack/profiles/__init__.py +22 -0
  99. rememberstack/profiles/selfhost.py +324 -0
  100. rememberstack/profiles/selfhost_forget.py +158 -0
  101. rememberstack/profiles/selfhost_operations.py +95 -0
  102. rememberstack/py.typed +1 -0
  103. rememberstack/spine/__init__.py +93 -0
  104. rememberstack/spine/admission.py +26 -0
  105. rememberstack/spine/backfill.py +168 -0
  106. rememberstack/spine/catalog_contract.py +742 -0
  107. rememberstack/spine/chunk_catalog.py +237 -0
  108. rememberstack/spine/claim_catalog.py +298 -0
  109. rememberstack/spine/clustering.py +740 -0
  110. rememberstack/spine/component_versions.py +208 -0
  111. rememberstack/spine/consumption.py +81 -0
  112. rememberstack/spine/deployment_bootstrap.py +445 -0
  113. rememberstack/spine/document_catalog.py +621 -0
  114. rememberstack/spine/entity_registry.py +205 -0
  115. rememberstack/spine/extension_packs.py +220 -0
  116. rememberstack/spine/fact_catalog.py +571 -0
  117. rememberstack/spine/forget.py +1753 -0
  118. rememberstack/spine/knowledge.py +5467 -0
  119. rememberstack/spine/lifecycle.py +1071 -0
  120. rememberstack/spine/migrations/__init__.py +1 -0
  121. rememberstack/spine/migrations/_helpers.py +153 -0
  122. rememberstack/spine/migrations/env.py +58 -0
  123. rememberstack/spine/migrations/script.py.mako +27 -0
  124. rememberstack/spine/migrations/versions/__init__.py +1 -0
  125. rememberstack/spine/migrations/versions/p0_02_0001_extensions_enums.py +189 -0
  126. rememberstack/spine/migrations/versions/p0_02_0002_infrastructure_registries.py +321 -0
  127. rememberstack/spine/migrations/versions/p0_02_0003_entities_evaluation_e0_e1.py +631 -0
  128. rememberstack/spine/migrations/versions/p0_02_0004_claims_facts_evidence.py +411 -0
  129. rememberstack/spine/migrations/versions/p0_02_0005_projection_knowledge_retrieval.py +391 -0
  130. rememberstack/spine/migrations/versions/p0_02_0006_partitions_views.py +158 -0
  131. rememberstack/spine/migrations/versions/p2_06_0007_invalidated_outcome.py +26 -0
  132. rememberstack/spine/migrations/versions/p3_01_0008_document_version_target.py +58 -0
  133. rememberstack/spine/migrations/versions/p3_05_0009_reconcile_stage.py +27 -0
  134. rememberstack/spine/migrations/versions/p3_07_0010_lifecycle_eval_suite.py +25 -0
  135. rememberstack/spine/migrations/versions/p4_01_0011_survivor_view_rewrite.py +57 -0
  136. rememberstack/spine/migrations/versions/p6_02_0012_knowledge_compile_recovery.py +58 -0
  137. rememberstack/spine/migrations/versions/p6_04_0013_knowledge_writer_ledger.py +46 -0
  138. rememberstack/spine/migrations/versions/p6_05_0014_knowledge_planner_runtime.py +217 -0
  139. rememberstack/spine/migrations/versions/p6_06_0015_authored_dispatch_runtime.py +38 -0
  140. rememberstack/spine/migrations/versions/p7_02_0016_operational_eval_suite.py +19 -0
  141. rememberstack/spine/migrations/versions/p7_05_0017_hard_forget.py +55 -0
  142. rememberstack/spine/observation_adjudication.py +778 -0
  143. rememberstack/spine/operations.py +298 -0
  144. rememberstack/spine/projection.py +662 -0
  145. rememberstack/spine/recipes.py +276 -0
  146. rememberstack/spine/resolver.py +763 -0
  147. rememberstack/spine/review.py +650 -0
  148. rememberstack/spine/settings.py +22 -0
  149. rememberstack/spine/supersession.py +510 -0
  150. rememberstack/spine/sync.py +128 -0
  151. rememberstack/spine/work_ledger.py +816 -0
  152. rememberstack/surfaces/__init__.py +110 -0
  153. rememberstack/surfaces/cli.py +447 -0
  154. rememberstack/surfaces/consumption_skill.py +87 -0
  155. rememberstack/surfaces/graph_queries.py +698 -0
  156. rememberstack/surfaces/http_api.py +377 -0
  157. rememberstack/surfaces/mcp.py +67 -0
  158. rememberstack/surfaces/query_engine.py +1591 -0
  159. rememberstack/surfaces/recipe_executor.py +185 -0
  160. rememberstack/surfaces/recipe_surface.py +219 -0
  161. rememberstack/surfaces/remote_mcp.py +133 -0
  162. rememberstack/surfaces/sdk.py +324 -0
  163. rememberstack/workers/__init__.py +155 -0
  164. rememberstack/workers/base.py +312 -0
  165. rememberstack/workers/e0.py +577 -0
  166. rememberstack/workers/e1.py +425 -0
  167. rememberstack/workers/e2.py +525 -0
  168. rememberstack/workers/e3.py +434 -0
  169. rememberstack/workers/forget.py +299 -0
  170. rememberstack/workers/knowledge_authored.py +146 -0
  171. rememberstack/workers/knowledge_driver.py +735 -0
  172. rememberstack/workers/knowledge_fact_sheet.py +123 -0
  173. rememberstack/workers/knowledge_planner.py +325 -0
  174. rememberstack/workers/knowledge_writer.py +393 -0
  175. rememberstack/workers/operations.py +42 -0
  176. rememberstack/workers/p1.py +234 -0
  177. rememberstack/workers/p2.py +513 -0
  178. rememberstack/workers/p2_analytics.py +276 -0
  179. rememberstack/workers/p3.py +673 -0
  180. rememberstack/workers/reconcile.py +485 -0
  181. rememberstack/workers/sync.py +168 -0
  182. rememberstack-0.1.0.dist-info/METADATA +213 -0
  183. rememberstack-0.1.0.dist-info/RECORD +186 -0
  184. rememberstack-0.1.0.dist-info/WHEEL +4 -0
  185. rememberstack-0.1.0.dist-info/entry_points.txt +2 -0
  186. rememberstack-0.1.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,124 @@
1
+ """System-shipped extension packs (registries §4, D15): the Work pack.
2
+
3
+ Extensions are not second-class: a pack type lives in the same entity space,
4
+ graph, ER machinery, and relations as core types — the tier is a governance
5
+ distinction (stability commitment, golden-set obligation), not a capability
6
+ one. Every pack type anchors to a core parent (extend-never-fork); the
7
+ installer refuses a pack whose anchors don't exist.
8
+ """
9
+
10
+ from dataclasses import dataclass
11
+ from typing import Final
12
+
13
+ _CORE_ROOTS: Final = (
14
+ "Person",
15
+ "Organization",
16
+ "Place",
17
+ "Document",
18
+ "Event",
19
+ "Concept",
20
+ "Project",
21
+ "Product",
22
+ )
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class PackEntityType:
27
+ """One extension entity type anchored to a core parent."""
28
+
29
+ type: str
30
+ parent_type: str
31
+ description: str
32
+ examples: tuple[str, ...] = ()
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class PackPredicate:
37
+ """One extension predicate with its domain/range signatures (D18)."""
38
+
39
+ predicate: str
40
+ description: str
41
+ signatures: tuple[tuple[str, str], ...]
42
+ synonyms: tuple[str, ...] = ()
43
+ is_change_prone: bool = False
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class ExtensionPack:
48
+ """A predefined bundle a deployment enables as one unit."""
49
+
50
+ pack_id: str
51
+ name: str
52
+ description: str
53
+ entity_types: tuple[PackEntityType, ...] = ()
54
+ predicates: tuple[PackPredicate, ...] = ()
55
+
56
+
57
+ def _to_any(*subjects: str) -> tuple[tuple[str, str], ...]:
58
+ """Signatures pairing each subject with every core root ("→ any")."""
59
+ return tuple(
60
+ (subject, object_root) for subject in subjects for object_root in _CORE_ROOTS
61
+ )
62
+
63
+
64
+ WORK_PACK: Final = ExtensionPack(
65
+ pack_id="work",
66
+ name="Work",
67
+ description=(
68
+ "Work-shaped concepts for assistant, agency, and project-management "
69
+ "deployments: tasks, decisions, and goals as first-class entities."
70
+ ),
71
+ entity_types=(
72
+ PackEntityType(
73
+ type="Task",
74
+ parent_type="Event",
75
+ description="an intended occurrence with a lifecycle",
76
+ examples=("migrate the billing tables", "draft the Q3 report"),
77
+ ),
78
+ PackEntityType(
79
+ type="Decision",
80
+ parent_type="Event",
81
+ description="a commitment made at a point in time",
82
+ examples=("adopt PostgreSQL", "freeze the API surface"),
83
+ ),
84
+ PackEntityType(
85
+ type="Goal",
86
+ parent_type="Concept",
87
+ description="a desired state — held, not occurring",
88
+ examples=("sub-second p99 latency", "SOC 2 compliance"),
89
+ ),
90
+ ),
91
+ predicates=(
92
+ PackPredicate(
93
+ predicate="blocks",
94
+ description="the subject task prevents progress on the object task",
95
+ signatures=(("Task", "Task"),),
96
+ ),
97
+ PackPredicate(
98
+ predicate="depends_on",
99
+ description="the subject task requires the object task first",
100
+ signatures=(("Task", "Task"),),
101
+ ),
102
+ PackPredicate(
103
+ predicate="concerns",
104
+ description="the subject task or decision is about the object",
105
+ signatures=_to_any("Task", "Decision"),
106
+ ),
107
+ PackPredicate(
108
+ predicate="decided_by",
109
+ description="who made the decision",
110
+ signatures=(("Decision", "Person"), ("Decision", "Organization")),
111
+ ),
112
+ PackPredicate(
113
+ predicate="assigned_to",
114
+ description="who is responsible for the task",
115
+ signatures=(("Task", "Person"), ("Task", "Organization")),
116
+ is_change_prone=True,
117
+ ),
118
+ PackPredicate(
119
+ predicate="pursues",
120
+ description="the project or organization works toward the goal",
121
+ signatures=(("Project", "Goal"), ("Organization", "Goal")),
122
+ ),
123
+ ),
124
+ )
@@ -0,0 +1,17 @@
1
+ """Pure canonical identity helpers for D74 hard-forget guards."""
2
+
3
+ import hashlib
4
+ import json
5
+ from uuid import UUID
6
+
7
+
8
+ def source_identity_hash(
9
+ *, deployment_id: UUID, source_kind: str, source_ref: str
10
+ ) -> str:
11
+ """Hash one unambiguous deployment-owned connector identity tuple."""
12
+ canonical = json.dumps(
13
+ [str(deployment_id), source_kind, source_ref],
14
+ ensure_ascii=False,
15
+ separators=(",", ":"),
16
+ ).encode("utf-8")
17
+ return hashlib.sha256(canonical).hexdigest()
@@ -0,0 +1,276 @@
1
+ """Pure authored-frontmatter parsing and declaration-lint helpers."""
2
+
3
+ import json
4
+ from pathlib import PurePosixPath
5
+ from typing import TypeAlias
6
+ from uuid import UUID
7
+
8
+ from pydantic import TypeAdapter
9
+ from pydantic import ValidationError
10
+
11
+ from rememberstack.model import CommunityRuleParams
12
+ from rememberstack.model import DocSetRuleParams
13
+ from rememberstack.model import EntityRuleParams
14
+ from rememberstack.model import KnowledgeAuthoredDeclaration
15
+ from rememberstack.model import KnowledgeCitation
16
+ from rememberstack.model import KnowledgeEvidenceRole
17
+ from rememberstack.model import KnowledgeRuleParams
18
+ from rememberstack.model import PredicateBeatRuleParams
19
+
20
+ _RULE_ADAPTER = TypeAdapter(KnowledgeRuleParams)
21
+ _FrontmatterItem: TypeAlias = str | dict[str, object]
22
+
23
+
24
+ class KnowledgeAuthoredDeclarationError(ValueError):
25
+ """An authored page carries malformed ``cites`` or ``watch`` declarations."""
26
+
27
+
28
+ def parse_knowledge_authored_frontmatter(
29
+ *, markdown: str
30
+ ) -> KnowledgeAuthoredDeclaration:
31
+ """Parse the strict JSON-compatible YAML subset used by authored pages.
32
+
33
+ ``cites`` and ``watch`` accept an inline JSON list or an indented list whose
34
+ items are shorthands/JSON objects. Other frontmatter keys are left to their
35
+ owning surface and ignored here.
36
+ """
37
+ lines = _frontmatter_lines(markdown=markdown)
38
+ if lines is None:
39
+ return KnowledgeAuthoredDeclaration()
40
+ declared = _declared_lists(lines=lines)
41
+ citations = (
42
+ None
43
+ if declared["cites"] is None
44
+ else tuple(_citation(item=item) for item in declared["cites"] or ())
45
+ )
46
+ watch_rules: tuple[KnowledgeRuleParams, ...] | None = None
47
+ page_paths: tuple[str, ...] | None = None
48
+ if declared["watch"] is not None:
49
+ rules: list[KnowledgeRuleParams] = []
50
+ paths: list[str] = []
51
+ for item in declared["watch"] or ():
52
+ rule, page_path = _watch(item=item)
53
+ if rule is not None:
54
+ rules.append(rule)
55
+ if page_path is not None:
56
+ paths.append(page_path)
57
+ watch_rules = tuple(rules)
58
+ page_paths = tuple(paths)
59
+ return KnowledgeAuthoredDeclaration(
60
+ citations=citations, watch_rules=watch_rules, watched_page_paths=page_paths
61
+ )
62
+
63
+
64
+ def authored_declaration_is_empty(
65
+ *, citation_count: int, watch_rule_count: int, page_watch_count: int
66
+ ) -> bool:
67
+ """Return whether an authored page has no ground and can never be alerted."""
68
+ if min(citation_count, watch_rule_count, page_watch_count) < 0:
69
+ raise ValueError("declaration counts must be non-negative")
70
+ return citation_count + watch_rule_count + page_watch_count == 0
71
+
72
+
73
+ def knowledge_citation_reference(*, citation: KnowledgeCitation) -> str:
74
+ """Render one citation as a stable delta identifier."""
75
+ if citation.claim_lineage_id is not None:
76
+ target = (
77
+ f"claim:{citation.claim_lineage_id}:{citation.claim_chunk_content_hash}"
78
+ )
79
+ elif citation.relation_id is not None:
80
+ target = f"relation:{citation.relation_id}"
81
+ else:
82
+ target = f"doc:{citation.doc_id}"
83
+ return f"{citation.role.value}:{target}"
84
+
85
+
86
+ def _frontmatter_lines(*, markdown: str) -> tuple[str, ...] | None:
87
+ """Return frontmatter lines, distinguishing no header from an unclosed one."""
88
+ lines = markdown.splitlines()
89
+ if not lines or lines[0].strip() != "---":
90
+ return None
91
+ try:
92
+ end = next(
93
+ index
94
+ for index, line in enumerate(lines[1:], start=1)
95
+ if line.strip() == "---"
96
+ )
97
+ except StopIteration as error:
98
+ raise KnowledgeAuthoredDeclarationError(
99
+ "authored frontmatter is missing its closing delimiter"
100
+ ) from error
101
+ return tuple(lines[1:end])
102
+
103
+
104
+ def _declared_lists(
105
+ *, lines: tuple[str, ...]
106
+ ) -> dict[str, tuple[_FrontmatterItem, ...] | None]:
107
+ """Extract the two owned list keys without parsing unrelated frontmatter."""
108
+ result: dict[str, tuple[_FrontmatterItem, ...] | None] = {
109
+ "cites": None,
110
+ "watch": None,
111
+ }
112
+ seen: set[str] = set()
113
+ index = 0
114
+ while index < len(lines):
115
+ line = lines[index]
116
+ if not line or line[0].isspace() or ":" not in line:
117
+ index += 1
118
+ continue
119
+ key, inline = line.split(":", maxsplit=1)
120
+ if key not in result:
121
+ index += 1
122
+ continue
123
+ if key in seen:
124
+ raise KnowledgeAuthoredDeclarationError(
125
+ f"authored frontmatter repeats {key!r}"
126
+ )
127
+ seen.add(key)
128
+ inline = inline.strip()
129
+ if inline:
130
+ result[key] = _inline_list(key=key, value=inline)
131
+ index += 1
132
+ continue
133
+ values: list[_FrontmatterItem] = []
134
+ index += 1
135
+ while index < len(lines):
136
+ nested = lines[index]
137
+ if nested and not nested[0].isspace():
138
+ break
139
+ stripped = nested.strip()
140
+ index += 1
141
+ if not stripped or stripped.startswith("#"):
142
+ continue
143
+ if not stripped.startswith("- "):
144
+ raise KnowledgeAuthoredDeclarationError(
145
+ f"{key!r} must be a flat list of shorthands or JSON objects"
146
+ )
147
+ values.append(_list_item(key=key, value=stripped[2:].strip()))
148
+ result[key] = tuple(values)
149
+ return result
150
+
151
+
152
+ def _inline_list(*, key: str, value: str) -> tuple[_FrontmatterItem, ...]:
153
+ """Parse one inline JSON list for a declaration key."""
154
+ try:
155
+ parsed = json.loads(value)
156
+ except json.JSONDecodeError as error:
157
+ raise KnowledgeAuthoredDeclarationError(
158
+ f"inline {key!r} must be valid JSON"
159
+ ) from error
160
+ if not isinstance(parsed, list):
161
+ raise KnowledgeAuthoredDeclarationError(f"{key!r} must be a list")
162
+ return tuple(_require_item(key=key, value=item) for item in parsed)
163
+
164
+
165
+ def _list_item(*, key: str, value: str) -> _FrontmatterItem:
166
+ """Parse JSON-looking list items and retain plain shorthands verbatim."""
167
+ if not value:
168
+ raise KnowledgeAuthoredDeclarationError(f"{key!r} contains an empty item")
169
+ if value[0] not in ('"', "{"):
170
+ return value
171
+ try:
172
+ parsed = json.loads(value)
173
+ except json.JSONDecodeError as error:
174
+ raise KnowledgeAuthoredDeclarationError(
175
+ f"{key!r} contains invalid JSON"
176
+ ) from error
177
+ return _require_item(key=key, value=parsed)
178
+
179
+
180
+ def _require_item(*, key: str, value: object) -> _FrontmatterItem:
181
+ """Reject declaration values outside the supported scalar/object shapes."""
182
+ if isinstance(value, str):
183
+ return value
184
+ if isinstance(value, dict) and all(isinstance(name, str) for name in value):
185
+ return value
186
+ raise KnowledgeAuthoredDeclarationError(
187
+ f"{key!r} items must be strings or JSON objects"
188
+ )
189
+
190
+
191
+ def _citation(*, item: _FrontmatterItem) -> KnowledgeCitation:
192
+ """Validate one citation object or compact evidence-reference string."""
193
+ if isinstance(item, dict):
194
+ try:
195
+ return KnowledgeCitation.model_validate(item)
196
+ except ValidationError as error:
197
+ raise KnowledgeAuthoredDeclarationError(
198
+ "invalid authored citation object"
199
+ ) from error
200
+ parts = item.split(":")
201
+ role = KnowledgeEvidenceRole.CITES
202
+ if parts[0] in {value.value for value in KnowledgeEvidenceRole}:
203
+ role = KnowledgeEvidenceRole(parts.pop(0))
204
+ if len(parts) == 2 and parts[0] in {"relation", "doc"}:
205
+ try:
206
+ target_id = UUID(parts[1])
207
+ except ValueError as error:
208
+ raise KnowledgeAuthoredDeclarationError(
209
+ "citation shorthand contains an invalid UUID"
210
+ ) from error
211
+ if parts[0] == "relation":
212
+ return KnowledgeCitation(role=role, relation_id=target_id)
213
+ return KnowledgeCitation(role=role, doc_id=target_id)
214
+ if len(parts) == 3 and parts[0] == "claim" and parts[2]:
215
+ try:
216
+ lineage_id = UUID(parts[1])
217
+ except ValueError as error:
218
+ raise KnowledgeAuthoredDeclarationError(
219
+ "claim citation contains an invalid lineage UUID"
220
+ ) from error
221
+ return KnowledgeCitation(
222
+ role=role, claim_lineage_id=lineage_id, claim_chunk_content_hash=parts[2]
223
+ )
224
+ raise KnowledgeAuthoredDeclarationError(
225
+ "citation shorthand must target claim, relation, or doc evidence"
226
+ )
227
+
228
+
229
+ def _watch(*, item: _FrontmatterItem) -> tuple[KnowledgeRuleParams | None, str | None]:
230
+ """Validate one rich rule object or compact evidence/page watch."""
231
+ if isinstance(item, dict):
232
+ try:
233
+ return _RULE_ADAPTER.validate_python(item), None
234
+ except ValidationError as error:
235
+ raise KnowledgeAuthoredDeclarationError(
236
+ "invalid authored watch-rule object"
237
+ ) from error
238
+ prefix, separator, raw_value = item.partition(":")
239
+ if not separator or not raw_value:
240
+ raise KnowledgeAuthoredDeclarationError(
241
+ "watch shorthand must contain a kind and value"
242
+ )
243
+ try:
244
+ if prefix == "entity":
245
+ return EntityRuleParams(entity_id=UUID(raw_value)), None
246
+ if prefix == "community":
247
+ return CommunityRuleParams(community_id=UUID(raw_value)), None
248
+ except ValueError as error:
249
+ raise KnowledgeAuthoredDeclarationError(
250
+ "watch shorthand contains an invalid UUID"
251
+ ) from error
252
+ if prefix == "predicate":
253
+ return PredicateBeatRuleParams(predicate=raw_value), None
254
+ if prefix == "doc_source":
255
+ return DocSetRuleParams(source_kind=raw_value), None
256
+ if prefix == "page":
257
+ return None, _page_watch_path(value=raw_value)
258
+ raise KnowledgeAuthoredDeclarationError(f"unsupported watch shorthand {prefix!r}")
259
+
260
+
261
+ def _page_watch_path(*, value: str) -> str:
262
+ """Normalize the design's extension-optional ``page:<path>`` shorthand."""
263
+ path = PurePosixPath(value)
264
+ if path.suffix == "":
265
+ path = path.with_suffix(".md")
266
+ normalized = str(path)
267
+ if (
268
+ path.is_absolute()
269
+ or ".." in path.parts
270
+ or normalized != (f"{value}.md" if PurePosixPath(value).suffix == "" else value)
271
+ or path.suffix != ".md"
272
+ ):
273
+ raise KnowledgeAuthoredDeclarationError(
274
+ "page watch must be a normalized relative Markdown path"
275
+ )
276
+ return normalized
@@ -0,0 +1,215 @@
1
+ """Pure scheduling and output-validation rules for Plane-K cycles (D45)."""
2
+
3
+ from collections.abc import Collection
4
+ from collections.abc import Sequence
5
+ import posixpath
6
+ from urllib.parse import unquote
7
+ from urllib.parse import urlsplit
8
+ from uuid import UUID
9
+
10
+ from markdown_it import MarkdownIt
11
+
12
+ from rememberstack.core.knowledge_hashing import knowledge_content_hash
13
+ from rememberstack.model import KnowledgeCitation
14
+ from rememberstack.model import KnowledgeCompileArtifact
15
+ from rememberstack.model import KnowledgeEvidenceTarget
16
+ from rememberstack.model import KnowledgePageCompileOutput
17
+
18
+
19
+ class KnowledgeCompileGraphError(ValueError):
20
+ """The persisted compiled-page parent graph cannot be scheduled safely."""
21
+
22
+
23
+ class KnowledgePageValidationError(ValueError):
24
+ """A page compiler returned output that the driver must not publish."""
25
+
26
+
27
+ def knowledge_compile_order(
28
+ *, artifacts: Sequence[KnowledgeCompileArtifact]
29
+ ) -> tuple[KnowledgeCompileArtifact, ...]:
30
+ """Order the potential stale/model propagation closure deterministically.
31
+
32
+ Active ancestors are traversed because a changed child summary can stale them
33
+ inside this cycle. Likewise, a stale shared model can stale every page in its
34
+ scope. The driver skips those potential dependants when the freshly compiled
35
+ summary is unchanged.
36
+ """
37
+ by_id = {artifact.artifact_id: artifact for artifact in artifacts}
38
+ if len(by_id) != len(artifacts):
39
+ raise KnowledgeCompileGraphError(
40
+ "compile graph contains duplicate artifact IDs"
41
+ )
42
+ deployments = {artifact.deployment_id for artifact in artifacts}
43
+ if len(deployments) > 1:
44
+ raise KnowledgeCompileGraphError("compile graph crosses deployments")
45
+ model_scopes = [
46
+ artifact.scope_id
47
+ for artifact in artifacts
48
+ if artifact.artifact_kind == "model_page"
49
+ ]
50
+ if len(model_scopes) != len(set(model_scopes)):
51
+ raise KnowledgeCompileGraphError(
52
+ "compile graph has multiple model pages for one scope"
53
+ )
54
+
55
+ depths: dict[UUID, int] = {}
56
+ visiting: set[UUID] = set()
57
+
58
+ def depth(*, artifact_id: UUID) -> int:
59
+ """Resolve one node's root distance while detecting parent cycles."""
60
+ known = depths.get(artifact_id)
61
+ if known is not None:
62
+ return known
63
+ if artifact_id in visiting:
64
+ raise KnowledgeCompileGraphError("compile graph contains a parent cycle")
65
+ visiting.add(artifact_id)
66
+ parent_id = by_id[artifact_id].parent_artifact_id
67
+ value = (
68
+ 0
69
+ if parent_id is None or parent_id not in by_id
70
+ else depth(artifact_id=parent_id) + 1
71
+ )
72
+ visiting.remove(artifact_id)
73
+ depths[artifact_id] = value
74
+ return value
75
+
76
+ for artifact_id in by_id:
77
+ depth(artifact_id=artifact_id)
78
+
79
+ selected = {artifact.artifact_id for artifact in artifacts if artifact.stale}
80
+ stale_model_scopes = {
81
+ artifact.scope_id
82
+ for artifact in artifacts
83
+ if artifact.stale and artifact.artifact_kind == "model_page"
84
+ }
85
+ selected.update(
86
+ artifact.artifact_id
87
+ for artifact in artifacts
88
+ if artifact.scope_id in stale_model_scopes
89
+ )
90
+ frontier = list(selected)
91
+ while frontier:
92
+ artifact_id = frontier.pop()
93
+ parent_id = by_id[artifact_id].parent_artifact_id
94
+ if parent_id is not None and parent_id in by_id and parent_id not in selected:
95
+ selected.add(parent_id)
96
+ frontier.append(parent_id)
97
+
98
+ scheduled = (artifact for artifact in artifacts if artifact.artifact_id in selected)
99
+ return tuple(
100
+ sorted(
101
+ scheduled,
102
+ key=lambda artifact: (
103
+ (
104
+ 0
105
+ if artifact.artifact_kind == "model_page" and artifact.stale
106
+ else 2
107
+ if artifact.parent_artifact_id is None
108
+ and artifact.git_path == "_index.md"
109
+ else 1
110
+ ),
111
+ -depths[artifact.artifact_id],
112
+ artifact.git_path,
113
+ str(artifact.artifact_id),
114
+ ),
115
+ )
116
+ )
117
+
118
+
119
+ def validate_knowledge_page_output(
120
+ *,
121
+ artifact: KnowledgeCompileArtifact,
122
+ output: KnowledgePageCompileOutput,
123
+ known_git_paths: Collection[str],
124
+ exclusions: Collection[KnowledgeEvidenceTarget],
125
+ ) -> None:
126
+ """Enforce artifact binding, content hash, exclusions, and internal links."""
127
+ compilation = output.compilation
128
+ if compilation.deployment_id != artifact.deployment_id:
129
+ raise KnowledgePageValidationError("compilation crosses deployments")
130
+ if compilation.artifact_id != artifact.artifact_id:
131
+ raise KnowledgePageValidationError("compilation targets a different artifact")
132
+ if compilation.content_hash != knowledge_content_hash(markdown=output.markdown):
133
+ raise KnowledgePageValidationError(
134
+ "compiled Markdown content hash does not match"
135
+ )
136
+
137
+ excluded = {_target_key(target=target) for target in exclusions}
138
+ used = {
139
+ _citation_target_key(citation=citation) for citation in compilation.citations
140
+ }
141
+ used.update(
142
+ target for target in excluded if f"{target[0]}:{target[1]}" in output.markdown
143
+ )
144
+ if overlap := excluded.intersection(used):
145
+ rendered = ", ".join(f"{kind}:{value}" for kind, value in sorted(overlap))
146
+ raise KnowledgePageValidationError(
147
+ f"compiled page uses excluded evidence: {rendered}"
148
+ )
149
+
150
+ unresolved = _unresolved_internal_links(
151
+ markdown=output.markdown,
152
+ source_path=artifact.git_path,
153
+ known_git_paths=known_git_paths,
154
+ )
155
+ if unresolved:
156
+ raise KnowledgePageValidationError(
157
+ f"compiled page has unresolved internal links: {', '.join(unresolved)}"
158
+ )
159
+
160
+
161
+ def _target_key(*, target: KnowledgeEvidenceTarget) -> tuple[str, str]:
162
+ """Return the role-independent identity of one exclusion target."""
163
+ if target.claim_lineage_id is not None:
164
+ return (
165
+ "claim_coordinate",
166
+ f"{target.claim_lineage_id}:{target.claim_chunk_content_hash}",
167
+ )
168
+ if target.relation_id is not None:
169
+ return "relation", str(target.relation_id)
170
+ if target.doc_id is not None:
171
+ return "doc", str(target.doc_id)
172
+ raise AssertionError("validated evidence target has no ID")
173
+
174
+
175
+ def _citation_target_key(*, citation: KnowledgeCitation) -> tuple[str, str]:
176
+ """Return the role-independent identity of one citation target."""
177
+ if citation.claim_lineage_id is not None:
178
+ return (
179
+ "claim_coordinate",
180
+ f"{citation.claim_lineage_id}:{citation.claim_chunk_content_hash}",
181
+ )
182
+ if citation.relation_id is not None:
183
+ return "relation", str(citation.relation_id)
184
+ if citation.doc_id is not None:
185
+ return "doc", str(citation.doc_id)
186
+ raise AssertionError("validated citation has no ID")
187
+
188
+
189
+ def _unresolved_internal_links(
190
+ *, markdown: str, source_path: str, known_git_paths: Collection[str]
191
+ ) -> tuple[str, ...]:
192
+ """Return relative Markdown links that do not resolve to registered artifacts."""
193
+ known = set(known_git_paths)
194
+ unresolved: set[str] = set()
195
+ for block in MarkdownIt("commonmark").parse(markdown):
196
+ tokens = (block, *(block.children or ()))
197
+ for token in tokens:
198
+ if token.type != "link_open":
199
+ continue
200
+ href = token.attrGet("href")
201
+ if not isinstance(href, str):
202
+ continue
203
+ parsed = urlsplit(href)
204
+ if parsed.scheme or parsed.netloc or not parsed.path:
205
+ continue
206
+ raw_path = unquote(parsed.path)
207
+ if raw_path.startswith("/"):
208
+ resolved = posixpath.normpath(raw_path.lstrip("/"))
209
+ else:
210
+ resolved = posixpath.normpath(
211
+ posixpath.join(posixpath.dirname(source_path), raw_path)
212
+ )
213
+ if resolved == ".." or resolved.startswith("../") or resolved not in known:
214
+ unresolved.add(href)
215
+ return tuple(sorted(unresolved))