rfnry-memory 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 (48) hide show
  1. rfnry_memory/__init__.py +153 -0
  2. rfnry_memory/chunking/__init__.py +0 -0
  3. rfnry_memory/chunking/chunker.py +39 -0
  4. rfnry_memory/chunking/splitter.py +93 -0
  5. rfnry_memory/chunking/structure.py +110 -0
  6. rfnry_memory/cli.py +178 -0
  7. rfnry_memory/errors.py +22 -0
  8. rfnry_memory/filters.py +104 -0
  9. rfnry_memory/fusion/__init__.py +13 -0
  10. rfnry_memory/fusion/provenance.py +14 -0
  11. rfnry_memory/fusion/relative_score.py +38 -0
  12. rfnry_memory/fusion/rrf.py +35 -0
  13. rfnry_memory/graph/__init__.py +0 -0
  14. rfnry_memory/graph/extraction.py +81 -0
  15. rfnry_memory/graph/identity.py +187 -0
  16. rfnry_memory/graph/placement.py +65 -0
  17. rfnry_memory/index/__init__.py +13 -0
  18. rfnry_memory/index/hnsw.py +39 -0
  19. rfnry_memory/index/ivfflat.py +33 -0
  20. rfnry_memory/judge.py +61 -0
  21. rfnry_memory/memory.py +1954 -0
  22. rfnry_memory/models.py +634 -0
  23. rfnry_memory/postgres/__init__.py +11 -0
  24. rfnry_memory/postgres/base.py +28 -0
  25. rfnry_memory/postgres/corpus.py +116 -0
  26. rfnry_memory/postgres/graph.py +422 -0
  27. rfnry_memory/postgres/language.py +52 -0
  28. rfnry_memory/postgres/locks.py +14 -0
  29. rfnry_memory/postgres/migrations.py +354 -0
  30. rfnry_memory/postgres/records.py +110 -0
  31. rfnry_memory/postgres/schema.py +93 -0
  32. rfnry_memory/postgres/search.py +286 -0
  33. rfnry_memory/postgres/sources.py +374 -0
  34. rfnry_memory/postgres/sql.py +133 -0
  35. rfnry_memory/postgres/stories.py +279 -0
  36. rfnry_memory/postgres/vectors.py +324 -0
  37. rfnry_memory/py.typed +0 -0
  38. rfnry_memory/ranking/__init__.py +13 -0
  39. rfnry_memory/ranking/bm25.py +46 -0
  40. rfnry_memory/ranking/cover_density.py +36 -0
  41. rfnry_memory/rows.py +110 -0
  42. rfnry_memory/structured.py +57 -0
  43. rfnry_memory/tools.py +897 -0
  44. rfnry_memory-0.1.0.dist-info/METADATA +29 -0
  45. rfnry_memory-0.1.0.dist-info/RECORD +48 -0
  46. rfnry_memory-0.1.0.dist-info/WHEEL +4 -0
  47. rfnry_memory-0.1.0.dist-info/entry_points.txt +2 -0
  48. rfnry_memory-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,153 @@
1
+ from rfnry_memory.errors import (
2
+ ConfigurationError,
3
+ InputError,
4
+ MemoryException,
5
+ ModelError,
6
+ NotFoundError,
7
+ StorageError,
8
+ )
9
+ from rfnry_memory.fusion import RRF, Fusion, RelativeScore
10
+ from rfnry_memory.index import HNSW, IVFFlat, VectorIndex
11
+ from rfnry_memory.memory import Memory
12
+ from rfnry_memory.models import (
13
+ Answer,
14
+ AnswerLike,
15
+ Audio,
16
+ Check,
17
+ CheckAnswer,
18
+ Choice,
19
+ ChoiceAnswer,
20
+ Chunk,
21
+ Chunking,
22
+ Content,
23
+ CorpusDescription,
24
+ CorpusFingerprint,
25
+ Counts,
26
+ Decided,
27
+ DecidedLike,
28
+ DecisionModel,
29
+ Document,
30
+ DocumentEmbeddingModel,
31
+ EmbeddingModel,
32
+ Entity,
33
+ EntityIn,
34
+ GenerationModel,
35
+ Hit,
36
+ Hits,
37
+ Image,
38
+ Ingested,
39
+ Judgement,
40
+ JudgementName,
41
+ Kind,
42
+ Language,
43
+ Match,
44
+ Message,
45
+ Neighborhood,
46
+ Part,
47
+ PillarReport,
48
+ Question,
49
+ QuestionLike,
50
+ Ref,
51
+ Relation,
52
+ RelationIn,
53
+ Remembered,
54
+ Reply,
55
+ ReplyLike,
56
+ Score,
57
+ ScoreAnswer,
58
+ Section,
59
+ Source,
60
+ SourceIn,
61
+ State,
62
+ Story,
63
+ Synced,
64
+ Text,
65
+ ToolCall,
66
+ ToolSpec,
67
+ Usage,
68
+ confidence,
69
+ )
70
+ from rfnry_memory.postgres import PostgresStorage
71
+ from rfnry_memory.ranking import BM25, CoverDensity, TextRanking
72
+ from rfnry_memory.tools import MemoryTools, Tool, Tools
73
+
74
+ __version__ = "0.1.0"
75
+
76
+ __all__ = [
77
+ "Answer",
78
+ "AnswerLike",
79
+ "Check",
80
+ "CheckAnswer",
81
+ "Choice",
82
+ "ChoiceAnswer",
83
+ "Chunk",
84
+ "Chunking",
85
+ "ConfigurationError",
86
+ "CorpusDescription",
87
+ "CorpusFingerprint",
88
+ "Counts",
89
+ "Decided",
90
+ "DecidedLike",
91
+ "DecisionModel",
92
+ "DocumentEmbeddingModel",
93
+ "EmbeddingModel",
94
+ "Entity",
95
+ "EntityIn",
96
+ "GenerationModel",
97
+ "HNSW",
98
+ "BM25",
99
+ "CoverDensity",
100
+ "TextRanking",
101
+ "RRF",
102
+ "RelativeScore",
103
+ "Fusion",
104
+ "Hit",
105
+ "Hits",
106
+ "IVFFlat",
107
+ "Ingested",
108
+ "InputError",
109
+ "Judgement",
110
+ "JudgementName",
111
+ "Kind",
112
+ "Match",
113
+ "Language",
114
+ "Memory",
115
+ "MemoryException",
116
+ "MemoryTools",
117
+ "Message",
118
+ "ModelError",
119
+ "Neighborhood",
120
+ "NotFoundError",
121
+ "PillarReport",
122
+ "PostgresStorage",
123
+ "Question",
124
+ "QuestionLike",
125
+ "Ref",
126
+ "Relation",
127
+ "RelationIn",
128
+ "Remembered",
129
+ "Reply",
130
+ "ReplyLike",
131
+ "Score",
132
+ "ScoreAnswer",
133
+ "Section",
134
+ "Source",
135
+ "SourceIn",
136
+ "State",
137
+ "StorageError",
138
+ "Story",
139
+ "Synced",
140
+ "Tool",
141
+ "ToolCall",
142
+ "ToolSpec",
143
+ "Tools",
144
+ "Usage",
145
+ "VectorIndex",
146
+ "confidence",
147
+ "Text",
148
+ "Image",
149
+ "Audio",
150
+ "Document",
151
+ "Part",
152
+ "Content",
153
+ ]
File without changes
@@ -0,0 +1,39 @@
1
+ from __future__ import annotations
2
+
3
+ from pydantic import BaseModel
4
+
5
+ from rfnry_memory.chunking.splitter import RecursiveTextSplitter
6
+ from rfnry_memory.chunking.structure import (
7
+ build_heading_spans,
8
+ find_atomic_regions,
9
+ section_path_at,
10
+ )
11
+
12
+
13
+ class Piece(BaseModel):
14
+ title: str | None
15
+ text: str
16
+
17
+
18
+ def chunk_text(text: str, *, chunk_chars: int, overlap_chars: int) -> list[Piece]:
19
+ text = text.strip()
20
+ if not text:
21
+ return []
22
+ regions = find_atomic_regions(text)
23
+ spans = build_heading_spans(text, regions)
24
+ splitter = RecursiveTextSplitter(chunk_size=chunk_chars, chunk_overlap=overlap_chars)
25
+ pieces: list[Piece] = []
26
+
27
+ def free(start: int, end: int) -> None:
28
+ for piece, offset in splitter.split_text_with_offsets(text[start:end]):
29
+ pieces.append(Piece(title=section_path_at(start + offset, spans), text=piece))
30
+
31
+ cursor = 0
32
+ for region in regions:
33
+ free(cursor, region.start)
34
+ pieces.append(
35
+ Piece(title=section_path_at(region.start, spans), text=region.content.strip())
36
+ )
37
+ cursor = region.end
38
+ free(cursor, len(text))
39
+ return [piece for piece in pieces if piece.text]
@@ -0,0 +1,93 @@
1
+ import re
2
+
3
+ SEPARATORS = ["\n\n", "\n", ". ", " ", ""]
4
+
5
+
6
+ def _split(text: str, separator: str) -> list[tuple[int, int]]:
7
+ if not separator:
8
+ return [(i, i + 1) for i in range(len(text))]
9
+ spans: list[tuple[int, int]] = []
10
+ prev = 0
11
+ for m in re.finditer(re.escape(separator), text):
12
+ spans.append((prev, m.start()))
13
+ prev = m.end()
14
+ spans.append((prev, len(text)))
15
+ return [(s, e) for s, e in spans if e > s]
16
+
17
+
18
+ class RecursiveTextSplitter:
19
+ def __init__(self, chunk_size: int, chunk_overlap: int) -> None:
20
+ self._chunk_size = chunk_size
21
+ self._chunk_overlap = chunk_overlap
22
+
23
+ def split_text_with_offsets(self, text: str) -> list[tuple[str, int]]:
24
+ return self._split(text, SEPARATORS, 0)
25
+
26
+ def _emit(self, text: str, start: int, end: int, base: int) -> list[tuple[str, int]]:
27
+ chunk = text[start:end]
28
+ s = start + len(chunk) - len(chunk.lstrip())
29
+ e = end - (len(chunk) - len(chunk.rstrip()))
30
+ if e <= s:
31
+ return []
32
+ return [(text[s:e], base + s)]
33
+
34
+ def _merge(self, spans: list[tuple[int, int]], separator: str) -> list[tuple[int, int]]:
35
+ joiner = len(separator)
36
+ chunks: list[tuple[int, int]] = []
37
+ current: list[tuple[int, int]] = []
38
+ total = 0
39
+ for span in spans:
40
+ size = span[1] - span[0]
41
+ if total + size + (joiner if current else 0) > self._chunk_size and current:
42
+ chunks.append((current[0][0], current[-1][1]))
43
+ while total > self._chunk_overlap or (
44
+ total + size + (joiner if current else 0) > self._chunk_size and total > 0
45
+ ):
46
+ total -= current[0][1] - current[0][0] + (joiner if len(current) > 1 else 0)
47
+ current = current[1:]
48
+ current.append(span)
49
+ total += size + (joiner if len(current) > 1 else 0)
50
+ if current:
51
+ chunks.append((current[0][0], current[-1][1]))
52
+ return chunks
53
+
54
+ def _split(self, text: str, separators: list[str], base: int) -> list[tuple[str, int]]:
55
+ if not text:
56
+ return []
57
+ if len(text) <= self._chunk_size:
58
+ return self._emit(text, 0, len(text), base)
59
+ separator = separators[-1]
60
+ remaining: list[str] = []
61
+ for i, sep in enumerate(separators):
62
+ if not sep:
63
+ separator = sep
64
+ break
65
+ if sep in text:
66
+ separator = sep
67
+ remaining = separators[i + 1 :]
68
+ break
69
+ found: list[tuple[str, int]] = []
70
+ good: list[tuple[int, int]] = []
71
+ for span in _split(text, separator):
72
+ piece = text[span[0] : span[1]]
73
+ if len(piece) < self._chunk_size:
74
+ good.append(span)
75
+ continue
76
+ for s, e in self._merge(good, separator):
77
+ found.extend(self._emit(text, s, e, base))
78
+ good = []
79
+ if remaining:
80
+ found.extend(self._split(piece, remaining, base + span[0]))
81
+ elif len(piece) > self._chunk_size:
82
+ step = max(1, self._chunk_size - self._chunk_overlap)
83
+ for start in range(0, len(piece), step):
84
+ found.extend(
85
+ self._emit(
86
+ piece, start, min(start + self._chunk_size, len(piece)), base + span[0]
87
+ )
88
+ )
89
+ else:
90
+ found.extend(self._emit(text, span[0], span[1], base))
91
+ for s, e in self._merge(good, separator):
92
+ found.extend(self._emit(text, s, e, base))
93
+ return found
@@ -0,0 +1,110 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from dataclasses import dataclass
5
+
6
+ _FENCE_OPEN_RE = re.compile(r"```[a-zA-Z0-9_+-]*")
7
+ _TABLE_RE = re.compile(
8
+ r"(?:^\|[^\n]+\|\n)"
9
+ r"(?:^\|[\s\-:|]+\|\n)"
10
+ r"(?:^\|[^\n]+\|\n?)+",
11
+ re.MULTILINE,
12
+ )
13
+ _HEADING_RE = re.compile(r"^(#{1,6})\s+(.+?)$", re.MULTILINE)
14
+
15
+
16
+ @dataclass
17
+ class AtomicRegion:
18
+ start: int
19
+ end: int
20
+ content: str
21
+
22
+
23
+ @dataclass
24
+ class HeadingSpan:
25
+ start: int
26
+ end: int
27
+ path: tuple[str, ...]
28
+
29
+
30
+ def _iter_code_fences(text: str) -> list[AtomicRegion]:
31
+ lines = text.splitlines(keepends=True)
32
+ starts: list[int] = []
33
+ pos = 0
34
+ for line in lines:
35
+ starts.append(pos)
36
+ pos += len(line)
37
+
38
+ regions: list[AtomicRegion] = []
39
+ i = 0
40
+ n = len(lines)
41
+ while i < n:
42
+ if _FENCE_OPEN_RE.fullmatch(lines[i].rstrip("\r\n")):
43
+ j = i + 1
44
+ while j < n and not lines[j].startswith("```"):
45
+ j += 1
46
+ if j < n:
47
+ start = starts[i]
48
+ end = starts[j] + 3
49
+ regions.append(AtomicRegion(start, end, text[start:end]))
50
+ i = j + 1
51
+ continue
52
+ i += 1
53
+ return regions
54
+
55
+
56
+ def find_atomic_regions(text: str) -> list[AtomicRegion]:
57
+ regions: list[AtomicRegion] = []
58
+ regions.extend(_iter_code_fences(text))
59
+ for m in _TABLE_RE.finditer(text):
60
+ regions.append(AtomicRegion(m.start(), m.end(), m.group(0)))
61
+ regions.sort(key=lambda r: (r.start, -r.end))
62
+ merged: list[AtomicRegion] = []
63
+ for region in regions:
64
+ if merged and region.end <= merged[-1].end:
65
+ continue
66
+ if merged and region.start < merged[-1].end:
67
+ prev = merged[-1]
68
+ merged[-1] = AtomicRegion(prev.start, region.end, text[prev.start : region.end])
69
+ continue
70
+ merged.append(region)
71
+ return merged
72
+
73
+
74
+ def build_heading_spans(
75
+ text: str,
76
+ exclude_regions: list[AtomicRegion] | None = None,
77
+ ) -> list[HeadingSpan]:
78
+ headings: list[tuple[int, int, str]] = []
79
+ exclude = exclude_regions or []
80
+ for m in _HEADING_RE.finditer(text):
81
+ offset = m.start()
82
+ if any(r.start <= offset < r.end for r in exclude):
83
+ continue
84
+ title = m.group(2).strip()
85
+ title = title.rstrip("#").rstrip() or title
86
+ headings.append((offset, len(m.group(1)), title))
87
+ if not headings:
88
+ return []
89
+ spans: list[HeadingSpan] = []
90
+ stack: list[tuple[int, str]] = []
91
+ for i, (offset, level, title) in enumerate(headings):
92
+ while stack and stack[-1][0] >= level:
93
+ stack.pop()
94
+ stack.append((level, title))
95
+ end = headings[i + 1][0] if i + 1 < len(headings) else len(text)
96
+ spans.append(
97
+ HeadingSpan(
98
+ start=offset,
99
+ end=end,
100
+ path=tuple(t for _, t in stack),
101
+ )
102
+ )
103
+ return spans
104
+
105
+
106
+ def section_path_at(offset: int, spans: list[HeadingSpan]) -> str | None:
107
+ for s in spans:
108
+ if s.start <= offset < s.end:
109
+ return " > ".join(s.path)
110
+ return None
rfnry_memory/cli.py ADDED
@@ -0,0 +1,178 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import asyncio
5
+ import sys
6
+ from functools import partial
7
+ from importlib.util import find_spec
8
+ from typing import Any
9
+ from urllib.parse import urlsplit
10
+
11
+ from rfnry_memory.errors import MemoryException
12
+
13
+ _WARNING = "take a snapshot of the database and stop app instances running the old version first"
14
+
15
+
16
+ def _parser() -> argparse.ArgumentParser:
17
+ parser = argparse.ArgumentParser(
18
+ prog="rfnry-memory", description="prepare and upgrade the database rfnry-memory runs on"
19
+ )
20
+ commands = parser.add_subparsers(dest="command", required=True)
21
+
22
+ def command(name: str, summary: str) -> argparse.ArgumentParser:
23
+ sub = commands.add_parser(name, help=summary, description=summary)
24
+ sub.add_argument("--url", help="postgres connection string; without it, the PG* variables")
25
+ sub.add_argument("--schema", default="memory", help="the postgres schema (default: memory)")
26
+ return sub
27
+
28
+ create = command("create", "create the schema with this version's tables")
29
+ vectors = create.add_mutually_exclusive_group(required=True)
30
+ vectors.add_argument("--dimension", type=int, help="the embedding dimension")
31
+ vectors.add_argument("--no-vector", action="store_true", help="create no vector columns")
32
+ create.add_argument("--no-graph", action="store_true", help="do not install pg_trgm")
33
+ create.add_argument("--language", help="the default text search configuration of new corpora")
34
+ create.add_argument("--sql", action="store_true", help="print the statements and run nothing")
35
+ command("check", "say whether the schema fits this version")
36
+ upgrade = command("upgrade", "carry the schema to this version")
37
+ upgrade.add_argument("--sql", action="store_true", help="print the statements and run nothing")
38
+ upgrade.add_argument("--yes", action="store_true", help="run without asking")
39
+ change = command("set", "change a setting of the schema")
40
+ setting = change.add_mutually_exclusive_group(required=True)
41
+ setting.add_argument("--dimension", type=int, help="the embedding dimension")
42
+ setting.add_argument("--graph", action="store_true", help="install pg_trgm")
43
+ setting.add_argument("--language", help="the default text search configuration of new corpora")
44
+ change.add_argument("--sql", action="store_true", help="print the statements and run nothing")
45
+ change.add_argument("--yes", action="store_true", help="run without asking")
46
+ return parser
47
+
48
+
49
+ def _scrubbed(text: str, url: str | None) -> str:
50
+ if not url:
51
+ return text
52
+ password = urlsplit(url).password
53
+ text = text.replace(url, "<url>")
54
+ return text.replace(f":{password}@", ":***@") if password else text
55
+
56
+
57
+ def _quoted(name: str) -> str:
58
+ return '"' + name.replace('"', '""') + '"'
59
+
60
+
61
+ async def _connect(args: argparse.Namespace) -> tuple[Any, str]:
62
+ import asyncpg
63
+
64
+ conn = await asyncpg.connect(args.url)
65
+ held = await conn.fetch(
66
+ "SELECT DISTINCT quote_ident(n.nspname) AS name FROM pg_extension e "
67
+ "JOIN pg_namespace n ON n.oid = e.extnamespace WHERE e.extname IN ('vector', 'pg_trgm') "
68
+ "AND n.nspname NOT IN ($1, 'public') ORDER BY 1",
69
+ args.schema,
70
+ )
71
+ path = [_quoted(args.schema), "public", *(r["name"] for r in held)]
72
+ setting = f"SET search_path TO {', '.join(path)}"
73
+ await conn.execute(setting)
74
+ return conn, setting
75
+
76
+
77
+ def _confirmed(args: argparse.Namespace) -> bool:
78
+ if args.yes:
79
+ return True
80
+ print(_WARNING)
81
+ try:
82
+ answer = input("run these steps? [y/N] ")
83
+ except (EOFError, KeyboardInterrupt):
84
+ print()
85
+ return False
86
+ return answer.strip().lower() in ("y", "yes")
87
+
88
+
89
+ async def _run(args: argparse.Namespace) -> int:
90
+ from rfnry_memory.postgres import migrations
91
+ from rfnry_memory.postgres.schema import _problems
92
+
93
+ conn, setting = await _connect(args)
94
+ try:
95
+ exists = await conn.fetchval(
96
+ "SELECT EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = $1)", args.schema
97
+ )
98
+ if not exists and args.command != "create":
99
+ print(
100
+ f"cli: there is no schema {args.schema} — run rfnry-memory create --schema {args.schema}",
101
+ file=sys.stderr,
102
+ )
103
+ return 1
104
+ if args.command == "check":
105
+ problems, held = await _problems(conn, dimension=None, graph=False)
106
+ if problems:
107
+ for problem in problems:
108
+ print(f"cli: {problem}", file=sys.stderr)
109
+ return 1
110
+ vectors = "no vectors" if held is None else f"vectors of {held} dimensions"
111
+ print(f"schema {args.schema} fits rfnry-memory: version {migrations.LATEST}, {vectors}")
112
+ return 0
113
+ before: list[str] = []
114
+ planner: migrations.Planner
115
+ if args.command == "create":
116
+ before = [f"CREATE SCHEMA IF NOT EXISTS {_quoted(args.schema)}"]
117
+ planner = partial(
118
+ migrations.plan_create,
119
+ dimension=None if args.no_vector else args.dimension,
120
+ graph=not args.no_graph,
121
+ language=args.language,
122
+ )
123
+ elif args.command == "upgrade":
124
+ planner = migrations.plan_upgrade
125
+ else:
126
+ planner = partial(
127
+ migrations.plan_set,
128
+ dimension=args.dimension,
129
+ graph=args.graph,
130
+ language=args.language,
131
+ )
132
+ shown = await migrations.apply(conn, planner, run=False, before=before)
133
+ if args.sql:
134
+ print(f"{setting};")
135
+ for statement in shown.statements:
136
+ print(f"{statement};")
137
+ return 0
138
+ if not shown.steps:
139
+ print(f"schema {args.schema}: nothing to do")
140
+ return 0
141
+ for step in shown.steps:
142
+ print(f" {step}")
143
+ if args.command != "create" and not _confirmed(args):
144
+ print("nothing was run")
145
+ return 0
146
+ await migrations.apply(conn, planner, expected=shown, before=before)
147
+ print(f"schema {args.schema} is at version {migrations.LATEST}")
148
+ return 0
149
+ finally:
150
+ await conn.close()
151
+
152
+
153
+ def main(argv: list[str] | None = None) -> int:
154
+ args = _parser().parse_args(argv)
155
+ if args.schema in ("public", "information_schema") or args.schema.startswith("pg_"):
156
+ print(
157
+ f"cli: memory is not created in {args.schema!r} — name a schema of its own",
158
+ file=sys.stderr,
159
+ )
160
+ return 1
161
+ if args.command in ("create", "set") and args.dimension is not None and args.dimension < 1:
162
+ print("cli: a dimension is a positive int", file=sys.stderr)
163
+ return 1
164
+ if find_spec("asyncpg") is None:
165
+ print("cli: install rfnry-memory[postgres] to use this command", file=sys.stderr)
166
+ return 1
167
+ try:
168
+ return asyncio.run(_run(args))
169
+ except MemoryException as exc:
170
+ print(_scrubbed(str(exc), args.url), file=sys.stderr)
171
+ return 1
172
+ except Exception as exc:
173
+ print(_scrubbed(f"cli: {type(exc).__name__}: {exc}", args.url), file=sys.stderr)
174
+ return 1
175
+
176
+
177
+ if __name__ == "__main__":
178
+ sys.exit(main())
rfnry_memory/errors.py ADDED
@@ -0,0 +1,22 @@
1
+ class MemoryException(Exception):
2
+ pass
3
+
4
+
5
+ class ConfigurationError(MemoryException):
6
+ pass
7
+
8
+
9
+ class InputError(MemoryException):
10
+ pass
11
+
12
+
13
+ class ModelError(MemoryException):
14
+ pass
15
+
16
+
17
+ class StorageError(MemoryException):
18
+ pass
19
+
20
+
21
+ class NotFoundError(MemoryException):
22
+ pass
@@ -0,0 +1,104 @@
1
+ from __future__ import annotations
2
+
3
+ from copy import deepcopy
4
+ from typing import Any
5
+
6
+ from rfnry_memory.errors import InputError
7
+
8
+ RESERVED = frozenset({"corpus_id"})
9
+ FACET_PREFIX = "metadata."
10
+
11
+
12
+ def _valid_list(values: list[Any]) -> bool:
13
+ if all(isinstance(v, str) for v in values):
14
+ return True
15
+ return all(isinstance(v, int) and not isinstance(v, bool) for v in values)
16
+
17
+
18
+ def validate_filters(filters: dict[str, Any] | None) -> None:
19
+ if not filters:
20
+ return
21
+ for key, condition in filters.items():
22
+ if not isinstance(key, str) or not key.strip():
23
+ raise InputError("filters: filter keys must be non-empty strings")
24
+ if key in RESERVED:
25
+ raise InputError(
26
+ f"filters: filter key {key!r} is reserved — pass it as a call argument"
27
+ )
28
+ if not key.startswith(FACET_PREFIX) or len(key) == len(FACET_PREFIX):
29
+ raise InputError(
30
+ f"filters: filter key {key!r} must address a facet as '{FACET_PREFIX}<key>'"
31
+ )
32
+ if isinstance(condition, list):
33
+ if not condition or not _valid_list(condition):
34
+ raise InputError(
35
+ f"filters: filter {key!r} must be a non-empty list of str or of int"
36
+ )
37
+ continue
38
+ if isinstance(condition, str | int):
39
+ continue
40
+ raise InputError(
41
+ f"filters: filter {key!r} must be str, int, bool or a list of str or of int"
42
+ )
43
+
44
+
45
+ def _lookup(metadata: dict[str, Any], path: str) -> tuple[Any, bool]:
46
+ current: Any = metadata
47
+ for part in path.split("."):
48
+ if not isinstance(current, dict) or part not in current:
49
+ return None, False
50
+ current = current[part]
51
+ return current, True
52
+
53
+
54
+ def _same(a: Any, b: Any) -> bool:
55
+ if isinstance(a, bool) != isinstance(b, bool):
56
+ return False
57
+ return bool(a == b)
58
+
59
+
60
+ def _condition_matches(value: Any, condition: Any) -> bool:
61
+ values = value if isinstance(value, list) else [value]
62
+ conditions = condition if isinstance(condition, list) else [condition]
63
+ return any(_same(v, c) for v in values for c in conditions)
64
+
65
+
66
+ def within(value: Any, bound: Any) -> bool:
67
+ values = value if isinstance(value, list) else [value]
68
+ bounds = bound if isinstance(bound, list) else [bound]
69
+ return bool(values) and all(any(_same(v, b) for b in bounds) for v in values)
70
+
71
+
72
+ def matches_filters(metadata: dict[str, Any], filters: dict[str, Any] | None) -> bool:
73
+ if not filters:
74
+ return True
75
+ for key, condition in filters.items():
76
+ value, found = _lookup(metadata, key[len(FACET_PREFIX) :])
77
+ if not found or not _condition_matches(value, condition):
78
+ return False
79
+ return True
80
+
81
+
82
+ def stamp(metadata: dict[str, Any] | None, filters: dict[str, Any]) -> dict[str, Any]:
83
+ stamped = deepcopy(metadata or {})
84
+ for key, value in filters.items():
85
+ *parents, leaf = key[len(FACET_PREFIX) :].split(".")
86
+ node: Any = stamped
87
+ for part in parents:
88
+ node = node.setdefault(part, {})
89
+ if not isinstance(node, dict):
90
+ raise InputError(
91
+ f"filters: metadata must agree with the bound filter {key!r} = {value!r}"
92
+ )
93
+ if leaf in node:
94
+ if not within(node[leaf], value):
95
+ raise InputError(
96
+ f"filters: metadata must agree with the bound filter {key!r} = {value!r}"
97
+ )
98
+ continue
99
+ if isinstance(value, list) and len(value) > 1:
100
+ raise InputError(
101
+ f"filters: metadata must name one of the values bound to {key!r}: {value!r}"
102
+ )
103
+ node[leaf] = value[0] if isinstance(value, list) else value
104
+ return stamped