zikaron 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 (162) hide show
  1. zikaron/__init__.py +1 -0
  2. zikaron/cli/__init__.py +1 -0
  3. zikaron/cli/main.py +114 -0
  4. zikaron/core/__init__.py +1 -0
  5. zikaron/core/clock.py +78 -0
  6. zikaron/core/config/__init__.py +1 -0
  7. zikaron/core/config/keys.py +395 -0
  8. zikaron/core/config/resolution.py +267 -0
  9. zikaron/core/consolidation/__init__.py +1 -0
  10. zikaron/core/consolidation/authorization.py +316 -0
  11. zikaron/core/consolidation/candidates.py +147 -0
  12. zikaron/core/consolidation/context.py +166 -0
  13. zikaron/core/consolidation/grouping.py +383 -0
  14. zikaron/core/consolidation/groups.py +490 -0
  15. zikaron/core/consolidation/payload.py +246 -0
  16. zikaron/core/consolidation/planning.py +192 -0
  17. zikaron/core/consolidation/rowstate.py +68 -0
  18. zikaron/core/consolidation/runs.py +306 -0
  19. zikaron/core/consolidation/serving.py +462 -0
  20. zikaron/core/consolidation/verbs.py +500 -0
  21. zikaron/core/errors.py +355 -0
  22. zikaron/core/events.py +748 -0
  23. zikaron/core/indexing/__init__.py +1 -0
  24. zikaron/core/indexing/acquisition.py +255 -0
  25. zikaron/core/indexing/chunking.py +368 -0
  26. zikaron/core/indexing/encoder.py +537 -0
  27. zikaron/core/indexing/lexical.py +86 -0
  28. zikaron/core/indexing/model_cache.py +93 -0
  29. zikaron/core/indexing/model_pin.py +89 -0
  30. zikaron/core/indexing/vectors.py +223 -0
  31. zikaron/core/indexing/writes.py +461 -0
  32. zikaron/core/knowledge/__init__.py +5 -0
  33. zikaron/core/knowledge/arms.py +104 -0
  34. zikaron/core/knowledge/builds.py +204 -0
  35. zikaron/core/knowledge/candidates.py +130 -0
  36. zikaron/core/knowledge/changes.py +175 -0
  37. zikaron/core/knowledge/chunking.py +376 -0
  38. zikaron/core/knowledge/counters.py +228 -0
  39. zikaron/core/knowledge/database.py +380 -0
  40. zikaron/core/knowledge/ddl.py +196 -0
  41. zikaron/core/knowledge/disposal.py +213 -0
  42. zikaron/core/knowledge/errors.py +166 -0
  43. zikaron/core/knowledge/files.py +202 -0
  44. zikaron/core/knowledge/git.py +385 -0
  45. zikaron/core/knowledge/groups.py +450 -0
  46. zikaron/core/knowledge/lexical.py +64 -0
  47. zikaron/core/knowledge/lifecycle.py +418 -0
  48. zikaron/core/knowledge/lock.py +277 -0
  49. zikaron/core/knowledge/meta.py +393 -0
  50. zikaron/core/knowledge/paths.py +55 -0
  51. zikaron/core/knowledge/pending.py +59 -0
  52. zikaron/core/knowledge/registry.py +264 -0
  53. zikaron/core/knowledge/repair.py +152 -0
  54. zikaron/core/knowledge/reporting.py +436 -0
  55. zikaron/core/knowledge/roots.py +91 -0
  56. zikaron/core/knowledge/scan.py +429 -0
  57. zikaron/core/knowledge/search.py +346 -0
  58. zikaron/core/knowledge/state.py +174 -0
  59. zikaron/core/knowledge/text.py +166 -0
  60. zikaron/core/knowledge/vectors.py +102 -0
  61. zikaron/core/knowledge/walk.py +264 -0
  62. zikaron/core/knowledge/writes.py +127 -0
  63. zikaron/core/records/__init__.py +1 -0
  64. zikaron/core/records/memory.py +961 -0
  65. zikaron/core/records/receipts.py +161 -0
  66. zikaron/core/records/supersession.py +221 -0
  67. zikaron/core/retrieval/__init__.py +1 -0
  68. zikaron/core/retrieval/arms.py +318 -0
  69. zikaron/core/retrieval/block.py +107 -0
  70. zikaron/core/retrieval/eligibility.py +164 -0
  71. zikaron/core/retrieval/query.py +327 -0
  72. zikaron/core/retrieval/ranking.py +260 -0
  73. zikaron/core/retrieval/reads.py +294 -0
  74. zikaron/core/retrieval/retrieve.py +158 -0
  75. zikaron/core/retrieval/similarity.py +87 -0
  76. zikaron/core/signals/__init__.py +34 -0
  77. zikaron/core/signals/contention.py +106 -0
  78. zikaron/core/signals/dedup.py +201 -0
  79. zikaron/core/signals/horizon.py +47 -0
  80. zikaron/core/signals/repair.py +161 -0
  81. zikaron/core/signals/retirement.py +83 -0
  82. zikaron/core/signals/sessions.py +105 -0
  83. zikaron/core/signals/writes.py +200 -0
  84. zikaron/core/store/__init__.py +1 -0
  85. zikaron/core/store/connection.py +202 -0
  86. zikaron/core/store/ddl.py +215 -0
  87. zikaron/core/store/embedder.py +45 -0
  88. zikaron/core/store/meta.py +152 -0
  89. zikaron/core/store/permissions.py +160 -0
  90. zikaron/core/store/store.py +408 -0
  91. zikaron/core/store/transactions.py +181 -0
  92. zikaron/core/write/__init__.py +33 -0
  93. zikaron/core/write/dedup.py +145 -0
  94. zikaron/core/write/tools.py +290 -0
  95. zikaron/doctor/__init__.py +1 -0
  96. zikaron/doctor/checks.py +220 -0
  97. zikaron/doctor/main.py +64 -0
  98. zikaron/harness/__init__.py +1 -0
  99. zikaron/harness/detect.py +92 -0
  100. zikaron/harness/spec.py +320 -0
  101. zikaron/hook/__init__.py +1 -0
  102. zikaron/hook/connect.py +379 -0
  103. zikaron/hook/envelope.py +106 -0
  104. zikaron/hook/failure.py +104 -0
  105. zikaron/hook/limits.py +61 -0
  106. zikaron/hook/main.py +118 -0
  107. zikaron/hook/push.py +183 -0
  108. zikaron/hook/rpc.py +85 -0
  109. zikaron/hook/spawn_warm.py +81 -0
  110. zikaron/hook/subagent_policy.py +57 -0
  111. zikaron/hook/tripwire.py +54 -0
  112. zikaron/hook/warm_helper.py +137 -0
  113. zikaron/hook/write_policy.py +319 -0
  114. zikaron/install/__init__.py +4 -0
  115. zikaron/install/__main__.py +18 -0
  116. zikaron/install/assets.py +394 -0
  117. zikaron/install/entries.py +370 -0
  118. zikaron/install/harness.py +185 -0
  119. zikaron/install/main.py +375 -0
  120. zikaron/install/targets.py +789 -0
  121. zikaron/install/writer.py +973 -0
  122. zikaron/knowledge/__init__.py +1 -0
  123. zikaron/knowledge/__main__.py +17 -0
  124. zikaron/knowledge/indexer/__init__.py +1 -0
  125. zikaron/knowledge/indexer/__main__.py +17 -0
  126. zikaron/knowledge/indexer/detach.py +83 -0
  127. zikaron/knowledge/indexer/main.py +187 -0
  128. zikaron/knowledge/main.py +466 -0
  129. zikaron/knowledge/scope.py +133 -0
  130. zikaron/mcp/__init__.py +6 -0
  131. zikaron/mcp/connection.py +583 -0
  132. zikaron/mcp/consolidator.py +316 -0
  133. zikaron/mcp/errors.py +73 -0
  134. zikaron/mcp/main.py +66 -0
  135. zikaron/mcp/primary.py +420 -0
  136. zikaron/mcp/server.py +96 -0
  137. zikaron/mcp/spill.py +328 -0
  138. zikaron/mcp/tool_names.py +67 -0
  139. zikaron/py.typed +0 -0
  140. zikaron/service/__init__.py +1 -0
  141. zikaron/service/asyncio_compat.py +126 -0
  142. zikaron/service/context.py +251 -0
  143. zikaron/service/dispatch.py +332 -0
  144. zikaron/service/dispatch_consolidation.py +397 -0
  145. zikaron/service/dispatch_knowledge.py +469 -0
  146. zikaron/service/envelope.py +166 -0
  147. zikaron/service/lifecycle.py +467 -0
  148. zikaron/service/log.py +96 -0
  149. zikaron/service/main.py +531 -0
  150. zikaron/service/params.py +168 -0
  151. zikaron/service/paths.py +181 -0
  152. zikaron/service/rpc.py +176 -0
  153. zikaron/service/security.py +156 -0
  154. zikaron/service/serialize.py +204 -0
  155. zikaron/service/serialize_knowledge.py +238 -0
  156. zikaron/service/server.py +416 -0
  157. zikaron-0.1.0.dist-info/METADATA +770 -0
  158. zikaron-0.1.0.dist-info/RECORD +162 -0
  159. zikaron-0.1.0.dist-info/WHEEL +5 -0
  160. zikaron-0.1.0.dist-info/entry_points.txt +4 -0
  161. zikaron-0.1.0.dist-info/licenses/LICENSE +21 -0
  162. zikaron-0.1.0.dist-info/top_level.txt +1 -0
zikaron/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Zikaron: a memory store for tribal knowledge, a knowledge index for institutional knowledge."""
@@ -0,0 +1 @@
1
+ """The `zikaron` command: the front door, and the only Zikaron name a person types."""
zikaron/cli/main.py ADDED
@@ -0,0 +1,114 @@
1
+ """`zikaron` — one console script dispatching to the commands a person runs.
2
+
3
+ **Why an umbrella at all.** `uv tool install` puts only a package's console scripts on `PATH`, never
4
+ the tool virtualenv's interpreter, so a command reachable solely as `python -m zikaron.<x>` is
5
+ reachable only by a user who first finds `~/.local/share/uv/tools/zikaron/bin/python`. The `python
6
+ -m` forms keep working and stay documented, for the case a host Python install makes real: several
7
+ virtualenvs, where naming the interpreter is the point.
8
+
9
+ **`zikaron-hook` and `zikaron-mcp` stay separate and are deliberately not reachable from here.**
10
+ Their absolute paths are written into harness configuration at install time
11
+ (`architecture.md` §"The install contract"), so folding them in would rewrite every installed config
12
+ to shorten two command lines no human types.
13
+
14
+ **Each subcommand is imported only when it is the one being run.** `doctor` reports on an
15
+ installation that may be broken in exactly the way that stops another subcommand importing — a
16
+ missing `aiosqlite` is enough — and a module-level import would let the diagnosis fail with the
17
+ traceback it exists to replace. `doctor`'s own imports are stdlib plus this package, bar
18
+ `sqlite_vec`, which it imports inside the check that reports on it.
19
+ """
20
+
21
+ import sys
22
+ from collections.abc import Callable, Sequence
23
+ from typing import Final, NamedTuple, Protocol
24
+
25
+ NAME: Final = "zikaron"
26
+
27
+
28
+ class Entry(Protocol):
29
+ """One subcommand's `main`, as this dispatcher calls it.
30
+
31
+ `prog` is passed rather than defaulted because each subcommand's parser owns its own `--help`,
32
+ and a usage line reading `python -m zikaron.install` under `zikaron install --help` would name
33
+ an invocation the reader did not type.
34
+ """
35
+
36
+ def __call__(self, argv: Sequence[str], /, *, prog: str) -> int: ...
37
+
38
+
39
+ class _Subcommand(NamedTuple):
40
+ summary: str
41
+ load: Callable[[], Entry]
42
+
43
+
44
+ def _install() -> Entry:
45
+ from zikaron.install.main import main # noqa: PLC0415
46
+
47
+ return main
48
+
49
+
50
+ def _knowledge() -> Entry:
51
+ from zikaron.knowledge.main import main # noqa: PLC0415
52
+
53
+ return main
54
+
55
+
56
+ def _doctor() -> Entry:
57
+ from zikaron.doctor.main import main # noqa: PLC0415
58
+
59
+ return main
60
+
61
+
62
+ #: The dispatch table and the help text are the same object, so a command cannot be runnable
63
+ #: without being listed or listed without being runnable. Order is the order `--help` prints.
64
+ _SUBCOMMANDS: Final[dict[str, _Subcommand]] = {
65
+ "install": _Subcommand(
66
+ "write Zikaron's hook, MCP and consolidator entries into a project", _install
67
+ ),
68
+ "knowledge": _Subcommand("manage this project's knowledge bases", _knowledge),
69
+ "doctor": _Subcommand("check that this machine can run Zikaron", _doctor),
70
+ }
71
+
72
+
73
+ def _usage() -> str:
74
+ width = max(len(name) for name in _SUBCOMMANDS)
75
+ listed = [f" {name:<{width}} {command.summary}" for name, command in _SUBCOMMANDS.items()]
76
+ return "\n".join(
77
+ [
78
+ f"usage: {NAME} <command> [options]",
79
+ "",
80
+ "commands:",
81
+ *listed,
82
+ "",
83
+ f"`{NAME} <command> --help` describes one command.",
84
+ ]
85
+ )
86
+
87
+
88
+ def main(argv: Sequence[str] | None = None) -> int:
89
+ """Dispatch on the first argument. Returns a process exit status.
90
+
91
+ Returns rather than exiting, so a test drives the whole front door in-process. The console
92
+ script generated from `[project.scripts]` is what turns the status into an exit.
93
+
94
+ A usage error is status 2, which is `argparse`'s own, so the umbrella and the parsers it
95
+ dispatches to cannot disagree about what a mistyped command line costs.
96
+ """
97
+ arguments = list(sys.argv[1:] if argv is None else argv)
98
+ if not arguments:
99
+ print(_usage(), file=sys.stderr)
100
+ return 2
101
+ name, rest = arguments[0], arguments[1:]
102
+ if name in ("-h", "--help"):
103
+ print(_usage())
104
+ return 0
105
+ command = _SUBCOMMANDS.get(name)
106
+ if command is None:
107
+ print(f"{NAME}: unknown command {name!r}", file=sys.stderr)
108
+ print(_usage(), file=sys.stderr)
109
+ return 2
110
+ return command.load()(rest, prog=f"{NAME} {name}")
111
+
112
+
113
+ if __name__ == "__main__":
114
+ sys.exit(main())
@@ -0,0 +1 @@
1
+ """The library: every behaviour of both stores, with no transport and no process concerns."""
zikaron/core/clock.py ADDED
@@ -0,0 +1,78 @@
1
+ """The one clock every layer reads, and the one format every stored instant is in.
2
+
3
+ A single implementation, because several stored times are compared against **each other** rather
4
+ than merely displayed. Two clocks with two formats — one naive and one aware, or one truncated —
5
+ would make each of those comparisons a coin flip on a boundary nobody looks at.
6
+
7
+ **The contract, stated here because several callers depend on it and none of them should have to
8
+ rediscover it.** Every instant is ISO-8601 in UTC, so **lexicographic order on these strings agrees
9
+ with temporal order**, and a reader may compare two of them with `<` without parsing. That is a
10
+ property of the format rather than a coincidence: the offset is always `+00:00`, the fields are
11
+ fixed-width and most-significant-first, and the one optional field — microseconds, which
12
+ `isoformat()` omits when they are zero — sorts correctly anyway because `+` precedes `.`, putting
13
+ `…:12+00:00` ahead of `…:12.000001+00:00`. Measured over every boundary case and several million
14
+ random pairs, and pinned by a test, so it is a checked claim rather than an observation that held
15
+ the day it was made.
16
+
17
+ Reliance on it comes in two kinds, and the second is why the contract has to be stated rather than
18
+ merely observed. **In Python**, a knowledge base's `files_remaining` rule compares its walk's
19
+ completion instant against its build's start with a plain `<`. **In SQL**, every `MIN`, `MAX` or
20
+ `ORDER BY` over a stored `at` is this same contract applied inside the database — the signal
21
+ queries take lexicographic minima over `event.at` and call the result the earliest instant, which
22
+ is true only because of it. That second kind cannot be rewritten to parse: there is no `datetime`
23
+ inside a SQLite aggregate, so the ordering is not a convenience there but the mechanism.
24
+
25
+ Arithmetic is the exception, and it parses: a consolidation lease is a start plus a duration, a
26
+ signal horizon is an event's instant plus a number of days, and the age of an indexer's lock is now
27
+ minus the instant it was taken. Adding to or subtracting from a string is not something ordering can
28
+ do for you, which is why the last of those lives here as `seconds_since` rather than beside its
29
+ caller.
30
+
31
+ **One of those results is itself stored** — the lease's `expires_at` — so the contract has to cover
32
+ it, and it does: parsing an aware UTC instant, adding a whole number of seconds and re-formatting
33
+ yields the same shape it started from, microseconds present or absent alike. The derivation is
34
+ closed under the format, which is what lets "one format" stay true of a value no clock read
35
+ produced.
36
+
37
+ It lives here rather than beside any one kind of record because it belongs to none of them. It was
38
+ previously a function of the memory-record module, whose own docstring then had to enumerate which
39
+ *tables* drew from it — a list that silently went stale the first time a second database started
40
+ storing instants, which is the drift this project has convicted itself of more than once. What is
41
+ above is deliberately a classification rather than a list: a new caller orders (in Python or in
42
+ SQL) or it does arithmetic, and adding one does not falsify anything written here.
43
+ """
44
+
45
+ from datetime import UTC, datetime
46
+
47
+
48
+ def timestamp() -> str:
49
+ """The current instant, ISO-8601 in UTC.
50
+
51
+ Every stored time in every database Zikaron owns either comes from here or is derived from one
52
+ of these by parse-add-`isoformat()` — the consolidation lease's `expires_at` is the only such
53
+ derivation — and that derivation preserves the format, so any two stored instants order
54
+ correctly under a plain string comparison. See this module's docstring for why that ordering is
55
+ a property of the format rather than luck, and for the arithmetic callers that parse instead.
56
+ """
57
+ return datetime.now(UTC).isoformat()
58
+
59
+
60
+ def seconds_since(instant: str) -> float | None:
61
+ """How long ago a stored instant was, or `None` if it cannot be read as one of these.
62
+
63
+ Arithmetic, so it parses: the ordering contract above answers *which came first* and says
64
+ nothing about how far apart two instants are.
65
+
66
+ `None` rather than a refusal, because what this serves is a report. A value that is not one of
67
+ these instants can only have been written by hand, and losing one field of a diagnostic is a
68
+ better outcome there than losing the diagnostic. A naive value is refused with the rest: it
69
+ names no point in time without a zone, and guessing one would make the answer depend on where
70
+ the reader is.
71
+ """
72
+ try:
73
+ parsed = datetime.fromisoformat(instant)
74
+ except ValueError:
75
+ return None
76
+ if parsed.tzinfo is None:
77
+ return None
78
+ return (datetime.now(UTC) - parsed).total_seconds()
@@ -0,0 +1 @@
1
+ """The declarative configuration key schema and the layered resolution over it."""
@@ -0,0 +1,395 @@
1
+ """The configuration key schema: every key, its type, its permitted range, and its default.
2
+
3
+ One table, because a key's default living in the code that reads it and its range living in a
4
+ validator elsewhere is how the two come to disagree. Everything a layer can be resolved from —
5
+ the built-in default, the accepted type, the accepted range, the TOML location, and whether the
6
+ store itself has a say — is declared here and nowhere else.
7
+
8
+ Resolution over the layered files is a separate concern and reads this table; this module holds
9
+ no file, no path, and no merge.
10
+ """
11
+
12
+ from collections.abc import Mapping
13
+ from dataclasses import dataclass
14
+ from enum import StrEnum
15
+ from types import MappingProxyType
16
+ from typing import Final
17
+
18
+
19
+ class ConfigSection(StrEnum):
20
+ """The TOML sections. They group keys for a reader's benefit and nothing nests deeper."""
21
+
22
+ EMBEDDING = "embedding"
23
+ INDEXING = "indexing"
24
+ RETRIEVAL = "retrieval"
25
+ DEDUP = "dedup"
26
+ CONSOLIDATION = "consolidation"
27
+ SERVICE = "service"
28
+ SIGNALS = "signals"
29
+
30
+
31
+ class StoreCoupling(StrEnum):
32
+ """How far a key may move without contradicting what `memory.db` already contains.
33
+
34
+ Scoped to the memory store specifically, because it is not the only database Zikaron owns: a
35
+ knowledge base seeds several of these keys into its own `meta` at creation and then ignores
36
+ later changes to them, which is a different policy under a different document. A key that
37
+ couples only to a knowledge base is `NONE` here, and says so in its own notes.
38
+
39
+ A configuration file states intent; a store records what was actually done. For most keys
40
+ those cannot conflict, but three describe how existing rows were produced:
41
+
42
+ - `NONE`: the file is the only home. A new value simply takes effect.
43
+ - `SOFT`: the store also records what was done. A disagreement leaves the store
44
+ heterogeneous — old rows stay valid — so it is worth reporting and not worth refusing.
45
+ - `HARD`: the store's record wins. A disagreement is a *request* for a rebuild, because
46
+ every existing row was produced by the recorded value and reinterpreting them under a new
47
+ one would be silently wrong rather than visibly stale.
48
+ """
49
+
50
+ NONE = "none"
51
+ SOFT = "soft"
52
+ HARD = "hard"
53
+
54
+
55
+ class ConfigUnit(StrEnum):
56
+ """The unit a key's number is in, for the keys whose name does not already say."""
57
+
58
+ NONE = "none"
59
+ SECONDS = "seconds"
60
+ DAYS = "days"
61
+ BYTES = "bytes"
62
+
63
+
64
+ @dataclass(frozen=True, slots=True)
65
+ class IntBounds:
66
+ """An inclusive integer interval. `maximum is None` means unbounded above."""
67
+
68
+ minimum: int
69
+ maximum: int | None = None
70
+
71
+ @property
72
+ def value_type(self) -> type[int]:
73
+ """The one Python type this bound accepts."""
74
+ return int
75
+
76
+ def permits(self, value: int) -> bool:
77
+ """Whether `value` lies inside the interval."""
78
+ if value < self.minimum:
79
+ return False
80
+ return self.maximum is None or value <= self.maximum
81
+
82
+
83
+ @dataclass(frozen=True, slots=True)
84
+ class FloatBounds:
85
+ """A real interval whose two ends are independently open or closed."""
86
+
87
+ minimum: float
88
+ maximum: float
89
+ minimum_inclusive: bool
90
+ maximum_inclusive: bool
91
+
92
+ @property
93
+ def value_type(self) -> type[float]:
94
+ """The one Python type this bound accepts."""
95
+ return float
96
+
97
+ def permits(self, value: float) -> bool:
98
+ """Whether `value` lies inside the interval, respecting each end's openness."""
99
+ above = value >= self.minimum if self.minimum_inclusive else value > self.minimum
100
+ below = value <= self.maximum if self.maximum_inclusive else value < self.maximum
101
+ return above and below
102
+
103
+
104
+ @dataclass(frozen=True, slots=True)
105
+ class StringBounds:
106
+ """A string constraint. Emptiness is the only thing any key has to say about its strings."""
107
+
108
+ non_empty: bool
109
+
110
+ @property
111
+ def value_type(self) -> type[str]:
112
+ """The one Python type this bound accepts."""
113
+ return str
114
+
115
+ def permits(self, value: str) -> bool:
116
+ """Whether `value` satisfies the constraint."""
117
+ return not self.non_empty or value != ""
118
+
119
+
120
+ type ConfigBounds = IntBounds | FloatBounds | StringBounds
121
+
122
+
123
+ @dataclass(frozen=True, slots=True)
124
+ class ConfigKey:
125
+ """One configuration key: where it lives, what it accepts, and its value with no file.
126
+
127
+ `bounds` carries the accepted type and the accepted range as one thing, so there is no
128
+ declared type sitting somewhere a range can contradict.
129
+
130
+ A key is checked against itself at construction, which makes an edit to this table that
131
+ puts a default outside its own range fail on import rather than on the first store that
132
+ happens to run without a configuration file.
133
+
134
+ Raises:
135
+ TypeError: the default is not of the type the bounds accept.
136
+ ValueError: the default is of the right type but outside the bounds.
137
+ """
138
+
139
+ section: ConfigSection
140
+ name: str
141
+ bounds: ConfigBounds
142
+ default: int | float | str
143
+ store_coupling: StoreCoupling = StoreCoupling.NONE
144
+ unit: ConfigUnit = ConfigUnit.NONE
145
+
146
+ def __post_init__(self) -> None:
147
+ if type(self.default) is not self.bounds.value_type:
148
+ raise TypeError(
149
+ f"{self.toml_path}: default {self.default!r} is not "
150
+ f"{self.bounds.value_type.__name__}"
151
+ )
152
+ if not self.accepts(self.default):
153
+ raise ValueError(f"{self.toml_path}: default {self.default!r} is out of range")
154
+
155
+ @property
156
+ def toml_path(self) -> str:
157
+ """Where this key is written in a configuration file, as `section.name`."""
158
+ return f"{self.section.value}.{self.name}"
159
+
160
+ @property
161
+ def value_type(self) -> type[int] | type[float] | type[str]:
162
+ """The one Python type an effective value for this key may have."""
163
+ return self.bounds.value_type
164
+
165
+ def accepts(self, value: object) -> bool:
166
+ """Whether `value` is exactly this key's type and inside its range.
167
+
168
+ The type test is exact rather than nominal: a subclass of `int` is not an `int` for this
169
+ purpose. `bool` is the case that matters in practice — a TOML `true` would otherwise be
170
+ read as 1 — but an enum member or any other `int` subclass is just as wrong, and a
171
+ configuration value that reaches here already carrying meaning has been parsed by
172
+ something other than the parser this schema is for.
173
+
174
+ Widening is a separate question: an integer written where a float is declared may or may
175
+ not be worth accepting, and deciding that belongs to whoever parses the file rather than
176
+ to the schema that says what the key is.
177
+ """
178
+ bounds = self.bounds
179
+ if isinstance(bounds, IntBounds):
180
+ return type(value) is int and bounds.permits(value)
181
+ if isinstance(bounds, FloatBounds):
182
+ return type(value) is float and bounds.permits(value)
183
+ return type(value) is str and bounds.permits(value)
184
+
185
+
186
+ CONFIG_KEYS: Final[tuple[ConfigKey, ...]] = (
187
+ ConfigKey(
188
+ ConfigSection.EMBEDDING,
189
+ "embed_model",
190
+ StringBounds(non_empty=True),
191
+ "BAAI/bge-small-en-v1.5",
192
+ store_coupling=StoreCoupling.HARD,
193
+ ),
194
+ ConfigKey(
195
+ ConfigSection.EMBEDDING,
196
+ "embed_dim",
197
+ IntBounds(1),
198
+ 384,
199
+ store_coupling=StoreCoupling.HARD,
200
+ ),
201
+ ConfigKey(
202
+ ConfigSection.EMBEDDING,
203
+ "embed_prefix_query",
204
+ StringBounds(non_empty=False),
205
+ # The trailing space is part of the prefix the model expects, and every retrieval
206
+ # figure was measured with this exact string. A paraphrase is a different pipeline,
207
+ # so the literal is quoted rather than described. Empty disables prefixing.
208
+ "Represent this sentence for searching relevant passages: ",
209
+ ),
210
+ ConfigKey(
211
+ ConfigSection.INDEXING,
212
+ "chunk_max_tokens",
213
+ IntBounds(64, 8192),
214
+ 450,
215
+ store_coupling=StoreCoupling.SOFT,
216
+ ),
217
+ ConfigKey(
218
+ ConfigSection.INDEXING,
219
+ "gist_max_tokens",
220
+ IntBounds(8, 256),
221
+ 64,
222
+ ),
223
+ ConfigKey(
224
+ ConfigSection.INDEXING,
225
+ "knowledge_max_file_bytes",
226
+ # The maximum is protective rather than arbitrary: text detection decodes a candidate
227
+ # whole, so this cap is what bounds the memory one file can cost. 64 MiB is far above
228
+ # any plausible text file and far below a figure that would matter.
229
+ IntBounds(1, 67_108_864),
230
+ 1_048_576,
231
+ unit=ConfigUnit.BYTES,
232
+ ),
233
+ ConfigKey(
234
+ ConfigSection.INDEXING,
235
+ "knowledge_embed_batch",
236
+ # 1 disables batching outright; the maximum is where one forward pass's memory stops being
237
+ # bounded by anything this project controls. Unlike its neighbour above it is not seeded
238
+ # into a knowledge base, because it describes how much work goes into one pass rather than
239
+ # anything about the index that results — a correct implementation's vectors do not depend
240
+ # on it at all.
241
+ IntBounds(1, 256),
242
+ 32,
243
+ ),
244
+ ConfigKey(
245
+ ConfigSection.RETRIEVAL,
246
+ "chunk_overfetch",
247
+ IntBounds(1, 64),
248
+ 8,
249
+ ),
250
+ ConfigKey(
251
+ ConfigSection.RETRIEVAL,
252
+ "fusion_depth",
253
+ IntBounds(1, 500),
254
+ 50,
255
+ ),
256
+ ConfigKey(
257
+ ConfigSection.RETRIEVAL,
258
+ "rrf_k",
259
+ IntBounds(1),
260
+ 60,
261
+ ),
262
+ ConfigKey(
263
+ ConfigSection.RETRIEVAL,
264
+ "supersession_penalty",
265
+ FloatBounds(0.0, 1.0, minimum_inclusive=False, maximum_inclusive=True),
266
+ 0.5,
267
+ ),
268
+ ConfigKey(
269
+ ConfigSection.RETRIEVAL,
270
+ "retired_penalty",
271
+ FloatBounds(0.0, 1.0, minimum_inclusive=False, maximum_inclusive=True),
272
+ 0.5,
273
+ ),
274
+ ConfigKey(
275
+ ConfigSection.RETRIEVAL,
276
+ "supersession_max_depth",
277
+ IntBounds(1, 1024),
278
+ 32,
279
+ ),
280
+ ConfigKey(
281
+ ConfigSection.RETRIEVAL,
282
+ "fts_query_max_terms",
283
+ IntBounds(1, 512),
284
+ 64,
285
+ ),
286
+ ConfigKey(
287
+ ConfigSection.RETRIEVAL,
288
+ "knowledge_max_chunks_per_file",
289
+ # The maximum is mechanical rather than a taste: a knowledge search returns at most 20
290
+ # results per corpus, so a per-file allowance above 20 could never bind on any call, and a
291
+ # range that admits values which cannot take effect is a range that misleads.
292
+ IntBounds(1, 20),
293
+ 2,
294
+ ),
295
+ ConfigKey(
296
+ ConfigSection.RETRIEVAL,
297
+ "knowledge_snippet_max_chars",
298
+ # Counted in Unicode code points, which is also why the ceiling is a sanity bound rather
299
+ # than a derivation: the response cap is in *bytes*, and 24,000 code points can be four
300
+ # times that many bytes, so this number is the response cap's own borrowed as an
301
+ # order-of-magnitude limit. Anywhere near it a single snippet is undeliverable whatever the
302
+ # encoding, which is all the ceiling has to establish. The floor still shows a line or two
303
+ # of context.
304
+ IntBounds(80, 24_000),
305
+ 1_200,
306
+ ),
307
+ ConfigKey(
308
+ ConfigSection.DEDUP,
309
+ "dedup_threshold",
310
+ FloatBounds(0.0, 1.0, minimum_inclusive=True, maximum_inclusive=True),
311
+ 0.80,
312
+ ),
313
+ ConfigKey(
314
+ ConfigSection.DEDUP,
315
+ "dedup_max",
316
+ IntBounds(0, 20),
317
+ 3,
318
+ ),
319
+ ConfigKey(
320
+ ConfigSection.CONSOLIDATION,
321
+ "mutual_k",
322
+ IntBounds(2, 50),
323
+ 5,
324
+ ),
325
+ ConfigKey(
326
+ ConfigSection.CONSOLIDATION,
327
+ "orphan_edge_cutoff",
328
+ FloatBounds(0.0, 1.0, minimum_inclusive=True, maximum_inclusive=True),
329
+ 0.65,
330
+ ),
331
+ ConfigKey(
332
+ ConfigSection.CONSOLIDATION,
333
+ "anchor_cutoff",
334
+ FloatBounds(0.0, 1.0, minimum_inclusive=True, maximum_inclusive=True),
335
+ 0.65,
336
+ ),
337
+ ConfigKey(
338
+ ConfigSection.CONSOLIDATION,
339
+ "group_max",
340
+ IntBounds(2, 64),
341
+ 12,
342
+ ),
343
+ ConfigKey(
344
+ ConfigSection.CONSOLIDATION,
345
+ "max_group_serves",
346
+ IntBounds(1, 16),
347
+ 3,
348
+ ),
349
+ ConfigKey(
350
+ ConfigSection.CONSOLIDATION,
351
+ "run_lease",
352
+ IntBounds(60, 86400),
353
+ 1800,
354
+ unit=ConfigUnit.SECONDS,
355
+ ),
356
+ ConfigKey(
357
+ ConfigSection.CONSOLIDATION,
358
+ "spill_threshold",
359
+ IntBounds(4096, 1_048_576),
360
+ 27000,
361
+ unit=ConfigUnit.BYTES,
362
+ ),
363
+ ConfigKey(
364
+ ConfigSection.SERVICE,
365
+ "idle_timeout",
366
+ IntBounds(60, 86400),
367
+ 1800,
368
+ unit=ConfigUnit.SECONDS,
369
+ ),
370
+ ConfigKey(
371
+ ConfigSection.SIGNALS,
372
+ "signal_horizon_days",
373
+ IntBounds(1, 3650),
374
+ 30,
375
+ unit=ConfigUnit.DAYS,
376
+ ),
377
+ )
378
+
379
+
380
+ def _index_by_name(keys: tuple[ConfigKey, ...]) -> Mapping[str, ConfigKey]:
381
+ """Index the schema by bare key name, rejecting a name that appears in two sections.
382
+
383
+ Names are unique across the whole schema, not merely within a section, because the
384
+ store-coupled keys are also recorded in a flat single-namespace table — a name that needed
385
+ its section to disambiguate it could not be written there at all.
386
+ """
387
+ index: dict[str, ConfigKey] = {}
388
+ for key in keys:
389
+ if key.name in index:
390
+ raise ValueError(f"configuration key name used twice: {key.name}")
391
+ index[key.name] = key
392
+ return MappingProxyType(index)
393
+
394
+
395
+ CONFIG_KEYS_BY_NAME: Final[Mapping[str, ConfigKey]] = _index_by_name(CONFIG_KEYS)