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.
- zikaron/__init__.py +1 -0
- zikaron/cli/__init__.py +1 -0
- zikaron/cli/main.py +114 -0
- zikaron/core/__init__.py +1 -0
- zikaron/core/clock.py +78 -0
- zikaron/core/config/__init__.py +1 -0
- zikaron/core/config/keys.py +395 -0
- zikaron/core/config/resolution.py +267 -0
- zikaron/core/consolidation/__init__.py +1 -0
- zikaron/core/consolidation/authorization.py +316 -0
- zikaron/core/consolidation/candidates.py +147 -0
- zikaron/core/consolidation/context.py +166 -0
- zikaron/core/consolidation/grouping.py +383 -0
- zikaron/core/consolidation/groups.py +490 -0
- zikaron/core/consolidation/payload.py +246 -0
- zikaron/core/consolidation/planning.py +192 -0
- zikaron/core/consolidation/rowstate.py +68 -0
- zikaron/core/consolidation/runs.py +306 -0
- zikaron/core/consolidation/serving.py +462 -0
- zikaron/core/consolidation/verbs.py +500 -0
- zikaron/core/errors.py +355 -0
- zikaron/core/events.py +748 -0
- zikaron/core/indexing/__init__.py +1 -0
- zikaron/core/indexing/acquisition.py +255 -0
- zikaron/core/indexing/chunking.py +368 -0
- zikaron/core/indexing/encoder.py +537 -0
- zikaron/core/indexing/lexical.py +86 -0
- zikaron/core/indexing/model_cache.py +93 -0
- zikaron/core/indexing/model_pin.py +89 -0
- zikaron/core/indexing/vectors.py +223 -0
- zikaron/core/indexing/writes.py +461 -0
- zikaron/core/knowledge/__init__.py +5 -0
- zikaron/core/knowledge/arms.py +104 -0
- zikaron/core/knowledge/builds.py +204 -0
- zikaron/core/knowledge/candidates.py +130 -0
- zikaron/core/knowledge/changes.py +175 -0
- zikaron/core/knowledge/chunking.py +376 -0
- zikaron/core/knowledge/counters.py +228 -0
- zikaron/core/knowledge/database.py +380 -0
- zikaron/core/knowledge/ddl.py +196 -0
- zikaron/core/knowledge/disposal.py +213 -0
- zikaron/core/knowledge/errors.py +166 -0
- zikaron/core/knowledge/files.py +202 -0
- zikaron/core/knowledge/git.py +385 -0
- zikaron/core/knowledge/groups.py +450 -0
- zikaron/core/knowledge/lexical.py +64 -0
- zikaron/core/knowledge/lifecycle.py +418 -0
- zikaron/core/knowledge/lock.py +277 -0
- zikaron/core/knowledge/meta.py +393 -0
- zikaron/core/knowledge/paths.py +55 -0
- zikaron/core/knowledge/pending.py +59 -0
- zikaron/core/knowledge/registry.py +264 -0
- zikaron/core/knowledge/repair.py +152 -0
- zikaron/core/knowledge/reporting.py +436 -0
- zikaron/core/knowledge/roots.py +91 -0
- zikaron/core/knowledge/scan.py +429 -0
- zikaron/core/knowledge/search.py +346 -0
- zikaron/core/knowledge/state.py +174 -0
- zikaron/core/knowledge/text.py +166 -0
- zikaron/core/knowledge/vectors.py +102 -0
- zikaron/core/knowledge/walk.py +264 -0
- zikaron/core/knowledge/writes.py +127 -0
- zikaron/core/records/__init__.py +1 -0
- zikaron/core/records/memory.py +961 -0
- zikaron/core/records/receipts.py +161 -0
- zikaron/core/records/supersession.py +221 -0
- zikaron/core/retrieval/__init__.py +1 -0
- zikaron/core/retrieval/arms.py +318 -0
- zikaron/core/retrieval/block.py +107 -0
- zikaron/core/retrieval/eligibility.py +164 -0
- zikaron/core/retrieval/query.py +327 -0
- zikaron/core/retrieval/ranking.py +260 -0
- zikaron/core/retrieval/reads.py +294 -0
- zikaron/core/retrieval/retrieve.py +158 -0
- zikaron/core/retrieval/similarity.py +87 -0
- zikaron/core/signals/__init__.py +34 -0
- zikaron/core/signals/contention.py +106 -0
- zikaron/core/signals/dedup.py +201 -0
- zikaron/core/signals/horizon.py +47 -0
- zikaron/core/signals/repair.py +161 -0
- zikaron/core/signals/retirement.py +83 -0
- zikaron/core/signals/sessions.py +105 -0
- zikaron/core/signals/writes.py +200 -0
- zikaron/core/store/__init__.py +1 -0
- zikaron/core/store/connection.py +202 -0
- zikaron/core/store/ddl.py +215 -0
- zikaron/core/store/embedder.py +45 -0
- zikaron/core/store/meta.py +152 -0
- zikaron/core/store/permissions.py +160 -0
- zikaron/core/store/store.py +408 -0
- zikaron/core/store/transactions.py +181 -0
- zikaron/core/write/__init__.py +33 -0
- zikaron/core/write/dedup.py +145 -0
- zikaron/core/write/tools.py +290 -0
- zikaron/doctor/__init__.py +1 -0
- zikaron/doctor/checks.py +220 -0
- zikaron/doctor/main.py +64 -0
- zikaron/harness/__init__.py +1 -0
- zikaron/harness/detect.py +92 -0
- zikaron/harness/spec.py +320 -0
- zikaron/hook/__init__.py +1 -0
- zikaron/hook/connect.py +379 -0
- zikaron/hook/envelope.py +106 -0
- zikaron/hook/failure.py +104 -0
- zikaron/hook/limits.py +61 -0
- zikaron/hook/main.py +118 -0
- zikaron/hook/push.py +183 -0
- zikaron/hook/rpc.py +85 -0
- zikaron/hook/spawn_warm.py +81 -0
- zikaron/hook/subagent_policy.py +57 -0
- zikaron/hook/tripwire.py +54 -0
- zikaron/hook/warm_helper.py +137 -0
- zikaron/hook/write_policy.py +319 -0
- zikaron/install/__init__.py +4 -0
- zikaron/install/__main__.py +18 -0
- zikaron/install/assets.py +394 -0
- zikaron/install/entries.py +370 -0
- zikaron/install/harness.py +185 -0
- zikaron/install/main.py +375 -0
- zikaron/install/targets.py +789 -0
- zikaron/install/writer.py +973 -0
- zikaron/knowledge/__init__.py +1 -0
- zikaron/knowledge/__main__.py +17 -0
- zikaron/knowledge/indexer/__init__.py +1 -0
- zikaron/knowledge/indexer/__main__.py +17 -0
- zikaron/knowledge/indexer/detach.py +83 -0
- zikaron/knowledge/indexer/main.py +187 -0
- zikaron/knowledge/main.py +466 -0
- zikaron/knowledge/scope.py +133 -0
- zikaron/mcp/__init__.py +6 -0
- zikaron/mcp/connection.py +583 -0
- zikaron/mcp/consolidator.py +316 -0
- zikaron/mcp/errors.py +73 -0
- zikaron/mcp/main.py +66 -0
- zikaron/mcp/primary.py +420 -0
- zikaron/mcp/server.py +96 -0
- zikaron/mcp/spill.py +328 -0
- zikaron/mcp/tool_names.py +67 -0
- zikaron/py.typed +0 -0
- zikaron/service/__init__.py +1 -0
- zikaron/service/asyncio_compat.py +126 -0
- zikaron/service/context.py +251 -0
- zikaron/service/dispatch.py +332 -0
- zikaron/service/dispatch_consolidation.py +397 -0
- zikaron/service/dispatch_knowledge.py +469 -0
- zikaron/service/envelope.py +166 -0
- zikaron/service/lifecycle.py +467 -0
- zikaron/service/log.py +96 -0
- zikaron/service/main.py +531 -0
- zikaron/service/params.py +168 -0
- zikaron/service/paths.py +181 -0
- zikaron/service/rpc.py +176 -0
- zikaron/service/security.py +156 -0
- zikaron/service/serialize.py +204 -0
- zikaron/service/serialize_knowledge.py +238 -0
- zikaron/service/server.py +416 -0
- zikaron-0.1.0.dist-info/METADATA +770 -0
- zikaron-0.1.0.dist-info/RECORD +162 -0
- zikaron-0.1.0.dist-info/WHEEL +5 -0
- zikaron-0.1.0.dist-info/entry_points.txt +4 -0
- zikaron-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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."""
|
zikaron/cli/__init__.py
ADDED
|
@@ -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())
|
zikaron/core/__init__.py
ADDED
|
@@ -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)
|