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,210 @@
1
+ """Pure deterministic Plane-K fact-sheet rendering (D45, WP-6.3)."""
2
+
3
+ from datetime import datetime
4
+ from enum import StrEnum
5
+ from uuid import UUID
6
+
7
+ from pydantic import TypeAdapter
8
+
9
+ from rememberstack.model import KnowledgeFactSheetFact
10
+ from rememberstack.model import KnowledgeFactSheetSnapshot
11
+ from rememberstack.model import KnowledgeRenderedFactSheet
12
+ from rememberstack.model import UTCDateTime
13
+
14
+ _UTC_ADAPTER = TypeAdapter(UTCDateTime)
15
+
16
+
17
+ class KnowledgeFactLifecycle(StrEnum):
18
+ """A fact's literal state at the fixed evidence snapshot timestamp."""
19
+
20
+ CURRENT = "current"
21
+ ENDED = "ended"
22
+ INVALIDATED = "invalidated"
23
+ NOT_YET_VALID = "not yet valid"
24
+
25
+
26
+ def render_knowledge_fact_sheet(
27
+ *,
28
+ snapshot: KnowledgeFactSheetSnapshot,
29
+ compiled_at: UTCDateTime,
30
+ citation_count: int,
31
+ candidate_count: int | None = None,
32
+ ) -> KnowledgeRenderedFactSheet:
33
+ """Render exact current relations, observation history, and open tensions."""
34
+ if citation_count < 0:
35
+ raise ValueError("citation_count must be non-negative")
36
+ if candidate_count is not None and candidate_count < 0:
37
+ raise ValueError("candidate_count must be non-negative")
38
+ compiled_at = _UTC_ADAPTER.validate_python(compiled_at)
39
+ current_relations = tuple(
40
+ sorted(
41
+ (
42
+ fact
43
+ for fact in snapshot.facts
44
+ if fact.kind == "relation"
45
+ and _lifecycle(fact=fact, evidence_as_of=snapshot.evidence_as_of)
46
+ is KnowledgeFactLifecycle.CURRENT
47
+ ),
48
+ key=lambda fact: (
49
+ -fact.evidence_count,
50
+ fact.label.casefold(),
51
+ str(fact.fact_id),
52
+ ),
53
+ )
54
+ )
55
+ observations = tuple(
56
+ sorted(
57
+ (fact for fact in snapshot.facts if fact.kind == "observation"),
58
+ key=lambda fact: (
59
+ _time_key(value=fact.valid_from),
60
+ _time_key(value=fact.ingested_at),
61
+ str(fact.fact_id),
62
+ ),
63
+ )
64
+ )
65
+ contradiction_groups = _open_contradiction_groups(facts=snapshot.facts)
66
+
67
+ lines = [
68
+ "## Fact sheet (generated)",
69
+ "",
70
+ "### Current relations",
71
+ "",
72
+ "| fact | valid since | evidence | reference |",
73
+ "|---|---|---:|---|",
74
+ ]
75
+ if current_relations:
76
+ lines.extend(
77
+ f"| {_cell(fact.label)} | {_timestamp(fact.valid_from)} | "
78
+ f"{fact.evidence_count} docs | `relation:{fact.fact_id}` |"
79
+ for fact in current_relations
80
+ )
81
+ else:
82
+ lines.append("| _None._ | — | 0 docs | — |")
83
+
84
+ lines.extend(
85
+ (
86
+ "",
87
+ "### Observation history",
88
+ "",
89
+ "| observation | valid from | valid until | state | evidence | reference |",
90
+ "|---|---|---|---|---:|---|",
91
+ )
92
+ )
93
+ if observations:
94
+ lines.extend(
95
+ f"| {_cell(fact.label)} | {_timestamp(fact.valid_from)} | "
96
+ f"{_timestamp(fact.valid_until)} | "
97
+ f"{_lifecycle(fact=fact, evidence_as_of=snapshot.evidence_as_of).value} | "
98
+ f"{fact.evidence_count} docs | `observation:{fact.fact_id}` |"
99
+ for fact in observations
100
+ )
101
+ else:
102
+ lines.append("| _None._ | — | — | — | 0 docs | — |")
103
+
104
+ lines.extend(("", "### Open contradictions", ""))
105
+ if contradiction_groups:
106
+ lines.extend(
107
+ (
108
+ "| group | fact | state | evidence | reference |",
109
+ "|---|---|---|---:|---|",
110
+ )
111
+ )
112
+ for group_id, members in contradiction_groups:
113
+ lines.extend(
114
+ f"| `{group_id}` | {_cell(fact.label)} | "
115
+ f"{_lifecycle(fact=fact, evidence_as_of=snapshot.evidence_as_of).value} | "
116
+ f"{fact.evidence_count} docs | `{fact.kind}:{fact.fact_id}` |"
117
+ for fact in members
118
+ )
119
+ else:
120
+ lines.append("_None._")
121
+
122
+ if candidate_count is None:
123
+ candidate_count = len(snapshot.input_snapshot.facts) + len(
124
+ snapshot.input_snapshot.claims
125
+ )
126
+ lines.extend(
127
+ (
128
+ "",
129
+ "---",
130
+ f"_compiled {_timestamp(compiled_at)} · "
131
+ f"evidence as of {_timestamp(snapshot.evidence_as_of)} · "
132
+ f"{candidate_count} candidates · {citation_count} citations_",
133
+ "",
134
+ )
135
+ )
136
+ return KnowledgeRenderedFactSheet(
137
+ markdown="\n".join(lines),
138
+ current_relation_count=len(current_relations),
139
+ observation_count=len(observations),
140
+ contradiction_group_count=len(contradiction_groups),
141
+ )
142
+
143
+
144
+ def compose_knowledge_page(
145
+ *, prose_markdown: str | None, fact_sheet_markdown: str
146
+ ) -> str:
147
+ """Compose a two-band page, or the fact-sheet band alone with zero prose."""
148
+ fact_sheet = fact_sheet_markdown.strip()
149
+ if not fact_sheet:
150
+ raise ValueError("fact_sheet_markdown must be non-empty")
151
+ if prose_markdown is None or not prose_markdown.strip():
152
+ return f"{fact_sheet}\n"
153
+ return f"{prose_markdown.rstrip()}\n\n---\n{fact_sheet}\n"
154
+
155
+
156
+ def _lifecycle(
157
+ *, fact: KnowledgeFactSheetFact, evidence_as_of: datetime
158
+ ) -> KnowledgeFactLifecycle:
159
+ """Classify one fact without confusing validity end with invalidation."""
160
+ if fact.invalidated_at is not None:
161
+ return KnowledgeFactLifecycle.INVALIDATED
162
+ if fact.valid_from is not None and fact.valid_from > evidence_as_of:
163
+ return KnowledgeFactLifecycle.NOT_YET_VALID
164
+ if fact.valid_until is not None and fact.valid_until <= evidence_as_of:
165
+ return KnowledgeFactLifecycle.ENDED
166
+ return KnowledgeFactLifecycle.CURRENT
167
+
168
+
169
+ def _open_contradiction_groups(
170
+ *, facts: tuple[KnowledgeFactSheetFact, ...]
171
+ ) -> tuple[tuple[UUID, tuple[KnowledgeFactSheetFact, ...]], ...]:
172
+ """Group every non-invalidated selected side of an unresolved contradiction."""
173
+ grouped: dict[UUID, list[KnowledgeFactSheetFact]] = {}
174
+ for fact in facts:
175
+ if fact.contradiction_group is None or fact.invalidated_at is not None:
176
+ continue
177
+ grouped.setdefault(fact.contradiction_group, []).append(fact)
178
+ return tuple(
179
+ (
180
+ group_id,
181
+ tuple(
182
+ sorted(
183
+ members,
184
+ key=lambda fact: (
185
+ fact.kind,
186
+ fact.label.casefold(),
187
+ str(fact.fact_id),
188
+ ),
189
+ )
190
+ ),
191
+ )
192
+ for group_id, members in sorted(grouped.items(), key=lambda item: str(item[0]))
193
+ )
194
+
195
+
196
+ def _timestamp(value: datetime | None) -> str:
197
+ """Render full UTC precision without locale or machine-timezone dependence."""
198
+ if value is None:
199
+ return "—"
200
+ return value.isoformat().replace("+00:00", "Z")
201
+
202
+
203
+ def _time_key(*, value: datetime | None) -> tuple[int, str]:
204
+ """Sort unknown starts first, then aware timestamps in chronological order."""
205
+ return (0, "") if value is None else (1, value.isoformat())
206
+
207
+
208
+ def _cell(value: str) -> str:
209
+ """Keep arbitrary fact labels inside one Markdown table cell."""
210
+ return " ".join(value.split()).replace("\\", "\\\\").replace("|", "\\|")
@@ -0,0 +1,68 @@
1
+ """Canonical Plane-K input hashing (D45/D54)."""
2
+
3
+ import hashlib
4
+ import json
5
+
6
+ from pydantic import JsonValue
7
+ from pydantic import TypeAdapter
8
+
9
+ from rememberstack.model import KnowledgeInputSnapshot
10
+
11
+ _JSON_ADAPTER = TypeAdapter(JsonValue)
12
+
13
+
14
+ def knowledge_inputs_hash(*, snapshot: KnowledgeInputSnapshot) -> str:
15
+ """Return the order-independent D45 staleness key for one page.
16
+
17
+ Relations and observations retain their state fingerprints. Claim-grain
18
+ candidates deliberately contain only ``(lineage, chunk_content_hash)``;
19
+ raw claim IDs and text are absent so a semantically unchanged extraction
20
+ generation cannot create a stale storm (D54).
21
+ """
22
+ payload = _JSON_ADAPTER.validate_python(
23
+ {
24
+ "facts": _sorted_unique(
25
+ values=[fact.model_dump(mode="json") for fact in snapshot.facts]
26
+ ),
27
+ "claims": _sorted_unique(
28
+ values=[claim.model_dump(mode="json") for claim in snapshot.claims]
29
+ ),
30
+ "rules": _sorted_unique(
31
+ values=[
32
+ {"kind": rule.kind.value, "params": rule.params}
33
+ for rule in snapshot.rules
34
+ ]
35
+ ),
36
+ "curation_hash": snapshot.curation_hash,
37
+ # Multiplicity is meaningful here: two children with identical
38
+ # summaries are still two DAG inputs, while candidate rules are a set.
39
+ "child_summary_hashes": sorted(snapshot.child_summary_hashes),
40
+ "shared_model_summary_hash": snapshot.shared_model_summary_hash,
41
+ "writer_version": snapshot.writer_version,
42
+ }
43
+ )
44
+ canonical = json.dumps(
45
+ payload, ensure_ascii=False, separators=(",", ":"), sort_keys=True
46
+ )
47
+ return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
48
+
49
+
50
+ def knowledge_summary_hash(*, summary: str) -> str:
51
+ """Hash a child/model page summary for inclusion in a parent's manifest."""
52
+ return hashlib.sha256(summary.encode("utf-8")).hexdigest()
53
+
54
+
55
+ def knowledge_content_hash(*, markdown: str) -> str:
56
+ """Hash the exact UTF-8 bytes of a compiled Markdown page."""
57
+ return hashlib.sha256(markdown.encode("utf-8")).hexdigest()
58
+
59
+
60
+ def _sorted_unique(*, values: list[JsonValue]) -> list[JsonValue]:
61
+ """Sort JSON values canonically and remove set-union duplicates."""
62
+ by_json = {
63
+ json.dumps(
64
+ value, ensure_ascii=False, separators=(",", ":"), sort_keys=True
65
+ ): value
66
+ for value in values
67
+ }
68
+ return [by_json[key] for key in sorted(by_json)]
@@ -0,0 +1,64 @@
1
+ """Pure Plane-K planning hashes, trigger selection, and D24-style banding."""
2
+
3
+ from decimal import Decimal
4
+ import hashlib
5
+ import json
6
+
7
+ from rememberstack.model import KnowledgeConvertKindProposal
8
+ from rememberstack.model import KnowledgePageKind
9
+ from rememberstack.model import KnowledgePlanBand
10
+ from rememberstack.model import KnowledgePlanningSnapshot
11
+ from rememberstack.model import KnowledgePlanProposal
12
+ from rememberstack.model import KnowledgePlanRunKind
13
+ from rememberstack.model import KnowledgePlanTrigger
14
+
15
+
16
+ def knowledge_planning_input_hash(*, snapshot: KnowledgePlanningSnapshot) -> str:
17
+ """Hash one canonical planning snapshot for replay and cost attribution."""
18
+ payload = json.dumps(
19
+ snapshot.model_dump(mode="json"), sort_keys=True, separators=(",", ":")
20
+ ).encode()
21
+ return hashlib.sha256(payload).hexdigest()
22
+
23
+
24
+ def primary_knowledge_plan_trigger(
25
+ *, snapshot: KnowledgePlanningSnapshot, run_kind: KnowledgePlanRunKind
26
+ ) -> KnowledgePlanTrigger:
27
+ """Choose one stable ledger trigger while retaining every input in the snapshot."""
28
+ if run_kind is KnowledgePlanRunKind.REFLECTION:
29
+ return KnowledgePlanTrigger.REFLECTION
30
+ if snapshot.orphan_aggregates:
31
+ return KnowledgePlanTrigger.ORPHAN_EVIDENCE
32
+ if snapshot.overflow_artifact_ids:
33
+ return KnowledgePlanTrigger.SIZE_OVERFLOW
34
+ if snapshot.community_ids:
35
+ return KnowledgePlanTrigger.COMMUNITY_CHANGE
36
+ if snapshot.writer_suggestions:
37
+ return KnowledgePlanTrigger.WRITER_SUGGESTION
38
+ return KnowledgePlanTrigger.HUMAN
39
+
40
+
41
+ def route_knowledge_plan(
42
+ *,
43
+ proposal: KnowledgePlanProposal,
44
+ run_kind: KnowledgePlanRunKind,
45
+ blast_radius: int,
46
+ auto_apply_max_expected_impact: Decimal,
47
+ ) -> tuple[KnowledgePlanBand, Decimal]:
48
+ """Route a proposal mechanically from impact, with the two binding exceptions."""
49
+ if blast_radius < 0:
50
+ raise ValueError("blast_radius must be non-negative")
51
+ if auto_apply_max_expected_impact < 0:
52
+ raise ValueError("auto-apply threshold must be non-negative")
53
+ expected_impact = Decimal(blast_radius) * (Decimal("1") - proposal.confidence)
54
+ requires_author_confirmation = (
55
+ isinstance(proposal, KnowledgeConvertKindProposal)
56
+ and proposal.to_kind is KnowledgePageKind.COMPILED
57
+ )
58
+ if (
59
+ run_kind is KnowledgePlanRunKind.REFLECTION
60
+ or requires_author_confirmation
61
+ or expected_impact > auto_apply_max_expected_impact
62
+ ):
63
+ return KnowledgePlanBand.REVIEW, expected_impact
64
+ return KnowledgePlanBand.AUTO_APPLY, expected_impact
@@ -0,0 +1,175 @@
1
+ """Deterministic bundle capping, rendering, and completeness accounting."""
2
+
3
+ from collections.abc import Collection
4
+ import json
5
+ from uuid import UUID
6
+
7
+ from rememberstack.model import KnowledgeCitation
8
+ from rememberstack.model import KnowledgeEvidenceRole
9
+ from rememberstack.model import KnowledgeEvidenceTarget
10
+ from rememberstack.model import KnowledgeWriterBundle
11
+ from rememberstack.model import KnowledgeWriterClaimGroup
12
+ from rememberstack.model import KnowledgeWriterCoverage
13
+
14
+
15
+ def cap_knowledge_writer_bundle(
16
+ *,
17
+ bundle: KnowledgeWriterBundle,
18
+ exclusions: Collection[KnowledgeEvidenceTarget],
19
+ residue_claim_limit: int,
20
+ evidence_claims_per_fact: int,
21
+ ) -> KnowledgeWriterBundle:
22
+ """Filter curation exclusions and cap D54 claim coordinates deterministically."""
23
+ if residue_claim_limit < 0 or evidence_claims_per_fact < 0:
24
+ raise ValueError("writer claim limits must be non-negative")
25
+ excluded_relations = {
26
+ item.relation_id for item in exclusions if item.relation_id is not None
27
+ }
28
+ excluded_claim_coordinates = {
29
+ (item.claim_lineage_id, item.claim_chunk_content_hash)
30
+ for item in exclusions
31
+ if item.claim_lineage_id is not None
32
+ }
33
+ excluded_docs = {item.doc_id for item in exclusions if item.doc_id is not None}
34
+ facts = tuple(
35
+ fact
36
+ for fact in bundle.fact_sheet.facts
37
+ if fact.kind != "relation" or fact.fact_id not in excluded_relations
38
+ )
39
+ fact_keys = {(fact.kind, fact.fact_id) for fact in facts}
40
+ groups: list[KnowledgeWriterClaimGroup] = []
41
+ for group in bundle.claim_groups:
42
+ if (
43
+ group.lineage_id in excluded_docs
44
+ or _claim_group_key(group) in excluded_claim_coordinates
45
+ ):
46
+ continue
47
+ claims = []
48
+ for claim in group.claims:
49
+ references = tuple(
50
+ reference
51
+ for reference in claim.fact_references
52
+ if (reference.kind, reference.fact_id) in fact_keys
53
+ )
54
+ if claim.fact_references and not references:
55
+ # Evidence for an excluded fact must not silently re-enter as
56
+ # uncategorized residue after its fact reference is stripped.
57
+ continue
58
+ claims.append(claim.model_copy(update={"fact_references": references}))
59
+ if claims:
60
+ groups.append(group.model_copy(update={"claims": tuple(claims)}))
61
+
62
+ ordered = tuple(sorted(groups, key=_claim_group_key))
63
+ by_fact: dict[tuple[str, UUID], list[KnowledgeWriterClaimGroup]] = {}
64
+ residue: list[KnowledgeWriterClaimGroup] = []
65
+ for group in ordered:
66
+ references = {
67
+ (reference.kind, reference.fact_id)
68
+ for claim in group.claims
69
+ for reference in claim.fact_references
70
+ }
71
+ if not references:
72
+ residue.append(group)
73
+ for reference in references:
74
+ by_fact.setdefault(reference, []).append(group)
75
+
76
+ selected: dict[tuple[UUID, str], KnowledgeWriterClaimGroup] = {
77
+ _claim_group_key(group): group for group in residue[:residue_claim_limit]
78
+ }
79
+ for fact in sorted(
80
+ facts, key=lambda item: (-item.evidence_count, item.kind, str(item.fact_id))
81
+ ):
82
+ for group in by_fact.get((fact.kind, fact.fact_id), ())[
83
+ :evidence_claims_per_fact
84
+ ]:
85
+ selected[_claim_group_key(group)] = group
86
+ offered = tuple(selected[key] for key in sorted(selected, key=_sortable_group_key))
87
+ return KnowledgeWriterBundle(
88
+ fact_sheet=bundle.fact_sheet.model_copy(update={"facts": facts}),
89
+ claim_groups=offered,
90
+ claim_candidate_count=len(ordered),
91
+ claims_cut_count=len(ordered) - len(offered),
92
+ )
93
+
94
+
95
+ def render_knowledge_writer_bundle(*, bundle: KnowledgeWriterBundle) -> str:
96
+ """Render only offered evidence into stable JSON with citation IDs inline."""
97
+ payload = {
98
+ "artifact_id": str(bundle.fact_sheet.artifact_id),
99
+ "deployment_id": str(bundle.fact_sheet.deployment_id),
100
+ "evidence_as_of": bundle.fact_sheet.evidence_as_of.isoformat(),
101
+ "facts": [item.model_dump(mode="json") for item in bundle.fact_sheet.facts],
102
+ "claim_groups": [item.model_dump(mode="json") for item in bundle.claim_groups],
103
+ "ledger": {
104
+ "fact_candidates_offered": len(bundle.fact_sheet.facts),
105
+ "claim_candidates_offered": len(bundle.claim_groups),
106
+ "claim_candidates_cut": bundle.claims_cut_count,
107
+ },
108
+ }
109
+ return f"{json.dumps(payload, sort_keys=True, indent=2)}\n"
110
+
111
+
112
+ def knowledge_writer_coverage(
113
+ *, bundle: KnowledgeWriterBundle, citations: tuple[KnowledgeCitation, ...]
114
+ ) -> KnowledgeWriterCoverage:
115
+ """Count direct and support-claim coverage without trusting writer-reported totals."""
116
+ cited_relations = {
117
+ item.relation_id for item in citations if item.relation_id is not None
118
+ }
119
+ cited_claims = {
120
+ (item.claim_lineage_id, item.claim_chunk_content_hash)
121
+ for item in citations
122
+ if item.claim_lineage_id is not None
123
+ }
124
+ cited_docs = {item.doc_id for item in citations if item.doc_id is not None}
125
+ supporting_claims = {
126
+ (item.claim_lineage_id, item.claim_chunk_content_hash)
127
+ for item in citations
128
+ if item.claim_lineage_id is not None
129
+ and item.role in (KnowledgeEvidenceRole.SUPPORTS, KnowledgeEvidenceRole.CITES)
130
+ }
131
+ supporting_docs = {
132
+ item.doc_id
133
+ for item in citations
134
+ if item.doc_id is not None
135
+ and item.role in (KnowledgeEvidenceRole.SUPPORTS, KnowledgeEvidenceRole.CITES)
136
+ }
137
+
138
+ cited_groups: set[tuple[UUID, str]] = set()
139
+ supported_facts: set[tuple[str, UUID]] = set()
140
+ for group in bundle.claim_groups:
141
+ coordinate = _claim_group_key(group)
142
+ group_used = group.lineage_id in cited_docs or coordinate in cited_claims
143
+ for claim in group.claims:
144
+ if coordinate in supporting_claims or claim.lineage_id in supporting_docs:
145
+ supported_facts.update(
146
+ (reference.kind, reference.fact_id)
147
+ for reference in claim.fact_references
148
+ if reference.stance == "supports"
149
+ )
150
+ if group_used:
151
+ cited_groups.add(_claim_group_key(group))
152
+
153
+ cited_facts = {
154
+ (fact.kind, fact.fact_id)
155
+ for fact in bundle.fact_sheet.facts
156
+ if (fact.kind == "relation" and fact.fact_id in cited_relations)
157
+ or (fact.kind, fact.fact_id) in supported_facts
158
+ }
159
+ candidate_count = len(bundle.fact_sheet.facts) + len(bundle.claim_groups)
160
+ cited_candidate_count = len(cited_facts) + len(cited_groups)
161
+ return KnowledgeWriterCoverage(
162
+ candidate_count=candidate_count,
163
+ cited_candidate_count=cited_candidate_count,
164
+ uncited_count=candidate_count - cited_candidate_count,
165
+ )
166
+
167
+
168
+ def _claim_group_key(group: KnowledgeWriterClaimGroup) -> tuple[UUID, str]:
169
+ """Return one D54 coordinate in its typed form."""
170
+ return group.lineage_id, group.chunk_content_hash
171
+
172
+
173
+ def _sortable_group_key(key: tuple[UUID, str]) -> tuple[str, str]:
174
+ """Convert one D54 coordinate to a stable total-order key."""
175
+ return str(key[0]), key[1]