lorebase-core 0.2.3__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 (94) hide show
  1. lorebase_core/__init__.py +1 -0
  2. lorebase_core/canonical/__init__.py +35 -0
  3. lorebase_core/canonical/hashing.py +128 -0
  4. lorebase_core/canonical/jcs.py +162 -0
  5. lorebase_core/expr/__init__.py +118 -0
  6. lorebase_core/expr/ast.py +443 -0
  7. lorebase_core/expr/checker.py +428 -0
  8. lorebase_core/expr/compiler.py +87 -0
  9. lorebase_core/expr/context.py +82 -0
  10. lorebase_core/expr/derivations.py +306 -0
  11. lorebase_core/expr/environment.py +176 -0
  12. lorebase_core/expr/errors.py +98 -0
  13. lorebase_core/expr/evaluator.py +414 -0
  14. lorebase_core/expr/functions.py +898 -0
  15. lorebase_core/expr/limits.py +52 -0
  16. lorebase_core/expr/operations.py +479 -0
  17. lorebase_core/expr/ops.py +243 -0
  18. lorebase_core/expr/resolver.py +96 -0
  19. lorebase_core/expr/rows.py +148 -0
  20. lorebase_core/expr/sensitivity.py +337 -0
  21. lorebase_core/expr/types.py +227 -0
  22. lorebase_core/expr/validation.py +51 -0
  23. lorebase_core/expr/values.py +169 -0
  24. lorebase_core/expr/visibility.py +363 -0
  25. lorebase_core/infer/__init__.py +36 -0
  26. lorebase_core/infer/columns.py +621 -0
  27. lorebase_core/infer/engine.py +546 -0
  28. lorebase_core/infer/keys.py +164 -0
  29. lorebase_core/infer/names.py +145 -0
  30. lorebase_core/infer/options.py +290 -0
  31. lorebase_core/infer/profile.py +54 -0
  32. lorebase_core/infer/proposal.py +122 -0
  33. lorebase_core/infer/references.py +314 -0
  34. lorebase_core/infer/suggestions.py +731 -0
  35. lorebase_core/infer/tables.py +81 -0
  36. lorebase_core/merge/__init__.py +89 -0
  37. lorebase_core/merge/conflicts.py +262 -0
  38. lorebase_core/merge/consistency.py +440 -0
  39. lorebase_core/merge/data_merge.py +522 -0
  40. lorebase_core/merge/engine.py +476 -0
  41. lorebase_core/merge/index.py +100 -0
  42. lorebase_core/merge/objects.py +204 -0
  43. lorebase_core/merge/policies.py +322 -0
  44. lorebase_core/merge/resolve.py +321 -0
  45. lorebase_core/merge/schema_merge.py +429 -0
  46. lorebase_core/merge/state.py +443 -0
  47. lorebase_core/model/__init__.py +245 -0
  48. lorebase_core/model/attribute.py +72 -0
  49. lorebase_core/model/base.py +61 -0
  50. lorebase_core/model/derivation.py +95 -0
  51. lorebase_core/model/domain.py +71 -0
  52. lorebase_core/model/embedding.py +193 -0
  53. lorebase_core/model/ids.py +78 -0
  54. lorebase_core/model/kind.py +85 -0
  55. lorebase_core/model/lineage.py +114 -0
  56. lorebase_core/model/operation.py +161 -0
  57. lorebase_core/model/paths.py +141 -0
  58. lorebase_core/model/record.py +127 -0
  59. lorebase_core/model/relation.py +83 -0
  60. lorebase_core/model/schema.py +219 -0
  61. lorebase_core/model/source.py +204 -0
  62. lorebase_core/model/trait.py +37 -0
  63. lorebase_core/model/vault.py +43 -0
  64. lorebase_core/model/yaml_schema.py +1058 -0
  65. lorebase_core/query/__init__.py +97 -0
  66. lorebase_core/query/dsl.py +227 -0
  67. lorebase_core/query/resolve.py +794 -0
  68. lorebase_core/revision/__init__.py +59 -0
  69. lorebase_core/revision/ancestry.py +272 -0
  70. lorebase_core/revision/changeset.py +62 -0
  71. lorebase_core/revision/revision.py +121 -0
  72. lorebase_core/tree/__init__.py +76 -0
  73. lorebase_core/tree/diff.py +119 -0
  74. lorebase_core/tree/hamt.py +333 -0
  75. lorebase_core/tree/node.py +280 -0
  76. lorebase_core/tree/params.py +33 -0
  77. lorebase_core/tree/root.py +70 -0
  78. lorebase_core/tree/store.py +98 -0
  79. lorebase_core/types/__init__.py +71 -0
  80. lorebase_core/types/casts.py +271 -0
  81. lorebase_core/types/constraints.py +299 -0
  82. lorebase_core/types/encoding.py +538 -0
  83. lorebase_core/types/errors.py +41 -0
  84. lorebase_core/types/matching.py +40 -0
  85. lorebase_core/types/types.py +187 -0
  86. lorebase_core/validate/__init__.py +49 -0
  87. lorebase_core/validate/changes.py +70 -0
  88. lorebase_core/validate/changeset.py +551 -0
  89. lorebase_core/validate/data_rules.py +291 -0
  90. lorebase_core/validate/issues.py +123 -0
  91. lorebase_core/validate/schema_rules.py +1304 -0
  92. lorebase_core-0.2.3.dist-info/METADATA +9 -0
  93. lorebase_core-0.2.3.dist-info/RECORD +94 -0
  94. lorebase_core-0.2.3.dist-info/WHEEL +4 -0
@@ -0,0 +1,72 @@
1
+ """Attribute (design 5.4): a typed field on a Kind, or a link Attribute on a Relation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import StrEnum
6
+
7
+ from pydantic import Field, JsonValue, field_validator
8
+
9
+ from lorebase_core.model.base import Model
10
+ from lorebase_core.model.ids import AttributeId, DerivationId, SourceId
11
+ from lorebase_core.types import Constraints, TypeSpec
12
+
13
+
14
+ class Sensitivity(StrEnum):
15
+ PII = "pii"
16
+ CONFIDENTIAL = "confidential"
17
+ RESTRICTED = "restricted"
18
+
19
+
20
+ class SearchWeight(StrEnum):
21
+ """Full-text weight of a searchable Attribute (``A`` is the strongest)."""
22
+
23
+ A = "A"
24
+ B = "B"
25
+ C = "C"
26
+ D = "D"
27
+
28
+
29
+ class Attribute(Model):
30
+ """A typed field. ``default`` is a value of ``type`` (stored in canonical form).
31
+
32
+ ``vault`` stores the values of a ``pii`` Attribute of a Kind in the vault (design 13.5):
33
+ Record objects hold a vault reference instead of the value (``lorebase_core.model.vault``).
34
+ Unset (None) keeps values inline; the field is then left out of canonical content, so
35
+ schemas written before it keep their hashes.
36
+ """
37
+
38
+ id: AttributeId
39
+ api_name: str
40
+ display_name: str = ""
41
+ description: str = ""
42
+ type: TypeSpec
43
+ required: bool = False
44
+ unique: bool = False
45
+ default: JsonValue = None
46
+ constraints: Constraints = Field(default_factory=Constraints)
47
+ sensitivity: tuple[Sensitivity, ...] = ()
48
+ indexed: bool = False
49
+ searchable: bool = False
50
+ search_weight: SearchWeight | None = None
51
+ derivation_id: DerivationId | None = None
52
+ authority: SourceId | None = None
53
+ vault: bool | None = None
54
+
55
+ @field_validator("sensitivity")
56
+ @classmethod
57
+ def _sort_sensitivity(cls, tags: tuple[Sensitivity, ...]) -> tuple[Sensitivity, ...]:
58
+ return tuple(sorted(set(tags)))
59
+
60
+ @property
61
+ def is_derived(self) -> bool:
62
+ return self.derivation_id is not None
63
+
64
+ @property
65
+ def is_sensitive(self) -> bool:
66
+ """Whether reads need a clearance for this Attribute's values (design 10.2)."""
67
+ return bool(self.sensitivity)
68
+
69
+ @property
70
+ def in_vault(self) -> bool:
71
+ """Whether values are stored in the vault, Record objects holding references."""
72
+ return self.vault is True
@@ -0,0 +1,61 @@
1
+ """Shared base classes and naming rules of schema elements."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from enum import StrEnum
7
+ from typing import ClassVar
8
+
9
+ from pydantic import BaseModel, ConfigDict, JsonValue
10
+
11
+
12
+ class Model(BaseModel):
13
+ """Frozen model base. ``content()`` is the JSON payload used for canonical hashing."""
14
+
15
+ model_config = ConfigDict(frozen=True, extra="forbid", populate_by_name=True)
16
+
17
+ def content(self) -> dict[str, JsonValue]:
18
+ """JSON-compatible payload: field aliases, absent (``None``) fields omitted."""
19
+ return self.model_dump(mode="json", by_alias=True, exclude_none=True)
20
+
21
+
22
+ class ElementStatus(StrEnum):
23
+ DRAFT = "draft"
24
+ ACTIVE = "active"
25
+ DEPRECATED = "deprecated"
26
+
27
+
28
+ class SchemaElement(Model):
29
+ """A versioned schema element: stable ``id`` plus renamable ``api_name`` (AC-01)."""
30
+
31
+ #: Object type used by the canonical hashing scheme (design 6.1).
32
+ object_type: ClassVar[str]
33
+
34
+ id: str
35
+ api_name: str
36
+ display_name: str = ""
37
+ description: str = ""
38
+
39
+
40
+ class NameStyle(StrEnum):
41
+ """Api name styles (design 5.1)."""
42
+
43
+ SNAKE = "snake_case"
44
+ PASCAL = "PascalCase"
45
+ DOTTED_SNAKE = "dotted.snake_case"
46
+
47
+
48
+ _NAME_PATTERNS: dict[NameStyle, re.Pattern[str]] = {
49
+ NameStyle.SNAKE: re.compile(r"[a-z][a-z0-9]*(?:_[a-z0-9]+)*"),
50
+ NameStyle.PASCAL: re.compile(r"[A-Z][A-Za-z0-9]*"),
51
+ NameStyle.DOTTED_SNAKE: re.compile(r"[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)*"),
52
+ }
53
+
54
+
55
+ def is_valid_api_name(name: str, style: NameStyle) -> bool:
56
+ return _NAME_PATTERNS[style].fullmatch(name) is not None
57
+
58
+
59
+ def snake_case(name: str) -> str:
60
+ """``RiskFlag`` -> ``risk_flag``; used to derive built-in Operation names."""
61
+ return re.sub(r"(?<=[a-z0-9])(?=[A-Z])", "_", name).lower()
@@ -0,0 +1,95 @@
1
+ """Derivation (design 5.8): a computed Attribute or Relation. Model only; no evaluation here.
2
+
3
+ Derived values are not part of Record content and never merge (AC-07).
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from enum import StrEnum
9
+ from typing import Annotated, ClassVar, Literal, Self
10
+
11
+ from pydantic import Field, model_validator
12
+
13
+ from lorebase_core.model.base import ElementStatus, Model, SchemaElement
14
+ from lorebase_core.model.ids import AttributeId, DerivationId, KindId, RelationId
15
+ from lorebase_core.model.operation import Expression
16
+ from lorebase_core.model.relation import LinkDirection
17
+
18
+
19
+ class AggregateFunction(StrEnum):
20
+ COUNT = "count"
21
+ SUM = "sum"
22
+ MIN = "min"
23
+ MAX = "max"
24
+ AVG = "avg"
25
+
26
+
27
+ class ExpressionDerivation(Model):
28
+ """A CEL expression over the Record's own Attributes, computing ``kind.attribute``."""
29
+
30
+ variant: Literal["expression"] = "expression"
31
+ kind: KindId
32
+ attribute: AttributeId
33
+ expression: Expression
34
+
35
+
36
+ class AggregateDerivation(Model):
37
+ """An aggregate over the Records reached from ``kind`` through ``relation`` in ``direction``.
38
+
39
+ ``relation`` is a Relation or a path Derivation (its virtual Relation).
40
+ ``source_attribute`` is an Attribute of the Kind at the far end; ``count`` needs none.
41
+ """
42
+
43
+ variant: Literal["aggregate"] = "aggregate"
44
+ kind: KindId
45
+ attribute: AttributeId
46
+ relation: RelationId | DerivationId
47
+ direction: LinkDirection = LinkDirection.FORWARD
48
+ function: AggregateFunction
49
+ source_attribute: AttributeId | None = None
50
+ filter: Expression | None = None
51
+
52
+ @model_validator(mode="after")
53
+ def _check_source(self) -> Self:
54
+ if self.function is not AggregateFunction.COUNT and self.source_attribute is None:
55
+ raise ValueError(f"aggregate {self.function} requires source_attribute")
56
+ return self
57
+
58
+
59
+ class PathStep(Model):
60
+ relation: RelationId
61
+ direction: LinkDirection = LinkDirection.FORWARD
62
+
63
+
64
+ class PathDerivation(Model):
65
+ """A composed Relation ``A -r1-> B -r2-> C`` exposed as a virtual Relation ``A -> C``.
66
+
67
+ The virtual Relation's name is the Derivation's ``api_name``.
68
+ """
69
+
70
+ variant: Literal["path"] = "path"
71
+ steps: tuple[PathStep, ...] = Field(min_length=2)
72
+ inverse_api_name: str | None = None
73
+ materialized: bool = False
74
+
75
+
76
+ DerivationDefinition = Annotated[
77
+ ExpressionDerivation | AggregateDerivation | PathDerivation,
78
+ Field(discriminator="variant"),
79
+ ]
80
+
81
+
82
+ class Derivation(SchemaElement):
83
+ object_type: ClassVar[str] = "schema.derivation"
84
+
85
+ id: DerivationId
86
+ definition: DerivationDefinition
87
+ status: ElementStatus = ElementStatus.ACTIVE
88
+
89
+ @property
90
+ def target(self) -> tuple[KindId, AttributeId] | None:
91
+ """The ``(kind, attribute)`` this Derivation computes; ``None`` for path Derivations."""
92
+ definition = self.definition
93
+ if isinstance(definition, ExpressionDerivation | AggregateDerivation):
94
+ return (definition.kind, definition.attribute)
95
+ return None
@@ -0,0 +1,71 @@
1
+ """Domain (design 5.2): the isolation boundary for data, permissions and storage."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated, ClassVar
6
+
7
+ from pydantic import Field, JsonValue, StringConstraints, field_validator
8
+
9
+ from lorebase_core.model.base import Model
10
+ from lorebase_core.types import AttributeType, TypeSpec, encode_value
11
+
12
+ DomainSlug = Annotated[str, StringConstraints(pattern=r"^[a-z][a-z0-9_-]*$")]
13
+
14
+ _TIMESTAMP = TypeSpec.of(AttributeType.TIMESTAMP)
15
+
16
+ #: Upper bounds of the example questions of a Domain; they are shown as buttons.
17
+ MAX_EXAMPLES = 20
18
+ MAX_EXAMPLE_LABEL = 80
19
+ MAX_EXAMPLE_QUESTION = 500
20
+
21
+
22
+ class DomainExample(Model):
23
+ """A sample question about the Domain, offered by clients as a ready-made prompt."""
24
+
25
+ label: Annotated[
26
+ str, StringConstraints(strip_whitespace=True, min_length=1, max_length=MAX_EXAMPLE_LABEL)
27
+ ]
28
+ question: Annotated[
29
+ str,
30
+ StringConstraints(strip_whitespace=True, min_length=1, max_length=MAX_EXAMPLE_QUESTION),
31
+ ]
32
+
33
+
34
+ class Domain(Model):
35
+ object_type: ClassVar[str] = "schema.domain"
36
+
37
+ id: DomainSlug
38
+ display_name: str = ""
39
+ description: str = ""
40
+ default_branch: str = "main"
41
+ settings: dict[str, JsonValue] = Field(default_factory=dict)
42
+ #: Optional sample questions; absent (not empty) when there are none, so the canonical
43
+ #: content of Domains written before this field existed is unchanged.
44
+ examples: tuple[DomainExample, ...] | None = Field(default=None, max_length=MAX_EXAMPLES)
45
+ #: Canonical RFC 3339 UTC timestamp.
46
+ created_at: str | None = None
47
+ created_by: str | None = None
48
+
49
+ @field_validator("created_at", mode="before")
50
+ @classmethod
51
+ def _canonical_timestamp(cls, value: object) -> object:
52
+ return encode_value(_TIMESTAMP, value)
53
+
54
+ @field_validator("examples", mode="before")
55
+ @classmethod
56
+ def _empty_examples_are_absent(cls, value: object) -> object:
57
+ if isinstance(value, list | tuple) and len(value) == 0:
58
+ return None
59
+ return value
60
+
61
+ @field_validator("examples")
62
+ @classmethod
63
+ def _unique_labels(
64
+ cls, value: tuple[DomainExample, ...] | None
65
+ ) -> tuple[DomainExample, ...] | None:
66
+ if value is not None:
67
+ labels = [example.label for example in value]
68
+ duplicates = sorted({label for label in labels if labels.count(label) > 1})
69
+ if duplicates:
70
+ raise ValueError(f"duplicate example labels: {', '.join(duplicates)}")
71
+ return value
@@ -0,0 +1,193 @@
1
+ """Embedding templates (design 8.4, 9.3): the text of a Record that Scribe embeds.
2
+
3
+ A Kind opts into semantic search by setting ``search.embedding_template``, a text with
4
+ ``{attribute}`` placeholders naming Attributes of the Kind by api name, plus ``{_kind}`` (the
5
+ Kind's display name, else its api name) and ``{_key}`` (the Record key). ``{{`` and ``}}``
6
+ write literal braces. Example::
7
+
8
+ search:
9
+ embedding_template: "{legal_name} ({country}). {description}"
10
+
11
+ Rendering is deterministic: absent values render as nothing, booleans as ``true``/``false``,
12
+ lists as their items joined by ``, ``, objects as compact JSON, and runs of whitespace collapse
13
+ to one space. Derived Attributes cannot be named (their values are not part of the Record's
14
+ content hash, which keys the stored vectors), and sensitive Attributes only when
15
+ ``search.embedding_sensitive`` is true.
16
+
17
+ ``KindTemplate.fingerprint`` changes whenever the rendered text of some Record could change
18
+ for reasons other than its values (template text, the Attributes the names resolve to, the
19
+ Kind name used by ``{_kind}``, the rendering rules), so a job can tell which Kinds need their
20
+ texts rendered again.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import hashlib
26
+ import json
27
+ import re
28
+ from collections.abc import Mapping
29
+ from dataclasses import dataclass
30
+
31
+ from pydantic import JsonValue
32
+
33
+ from lorebase_core.model.kind import Kind
34
+
35
+ RENDERING_VERSION = 1
36
+ """Bumped when the rendering rules change, which changes every fingerprint."""
37
+
38
+ KIND_FIELD = "_kind"
39
+ KEY_FIELD = "_key"
40
+ SPECIAL_FIELDS = frozenset({KIND_FIELD, KEY_FIELD})
41
+
42
+ _TOKEN = re.compile(r"\{\{|\}\}|\{([^{}]*)\}|[{}]")
43
+ _WHITESPACE = re.compile(r"\s+")
44
+
45
+
46
+ class TemplateError(ValueError):
47
+ """An embedding template that cannot be parsed or names something it cannot use."""
48
+
49
+
50
+ @dataclass(frozen=True, slots=True)
51
+ class Placeholder:
52
+ name: str
53
+
54
+
55
+ @dataclass(frozen=True, slots=True)
56
+ class EmbeddingTemplate:
57
+ """A parsed template: literal text and placeholders, in order."""
58
+
59
+ source: str
60
+ parts: tuple[str | Placeholder, ...]
61
+
62
+ @property
63
+ def fields(self) -> tuple[str, ...]:
64
+ """Placeholder names in order of first use."""
65
+ return tuple(dict.fromkeys(p.name for p in self.parts if isinstance(p, Placeholder)))
66
+
67
+
68
+ def parse_template(source: str) -> EmbeddingTemplate:
69
+ """Parse ``source``; `TemplateError` on an unbalanced brace or an empty placeholder."""
70
+ parts: list[str | Placeholder] = []
71
+ literal: list[str] = []
72
+ position = 0
73
+ for match in _TOKEN.finditer(source):
74
+ literal.append(source[position : match.start()])
75
+ position = match.end()
76
+ token = match.group(0)
77
+ if token in ("{{", "}}"):
78
+ literal.append(token[0])
79
+ continue
80
+ name = match.group(1)
81
+ if name is None:
82
+ raise TemplateError(
83
+ f"unbalanced {token!r} at offset {match.start()}; write {token * 2}"
84
+ )
85
+ name = name.strip()
86
+ if not name:
87
+ raise TemplateError(f"empty placeholder at offset {match.start()}")
88
+ if any(literal):
89
+ parts.append("".join(literal))
90
+ literal = []
91
+ parts.append(Placeholder(name))
92
+ literal.append(source[position:])
93
+ if any(literal):
94
+ parts.append("".join(literal))
95
+ if not any(isinstance(p, Placeholder) for p in parts):
96
+ raise TemplateError("the template names no Attribute")
97
+ return EmbeddingTemplate(source, tuple(parts))
98
+
99
+
100
+ @dataclass(frozen=True, slots=True)
101
+ class KindTemplate:
102
+ """The embedding template of one Kind, resolved against its Attributes."""
103
+
104
+ kind_id: str
105
+ template: EmbeddingTemplate
106
+ attributes: Mapping[str, str]
107
+ """Placeholder name -> Attribute id (special fields are not listed)."""
108
+ kind_label: str
109
+ fingerprint: str
110
+
111
+ def render(self, key: str, values: Mapping[str, JsonValue]) -> str:
112
+ """The text of the Record ``key`` with ``values`` (keyed by Attribute id)."""
113
+ out: list[str] = []
114
+ for part in self.template.parts:
115
+ if isinstance(part, str):
116
+ out.append(part)
117
+ elif part.name == KIND_FIELD:
118
+ out.append(self.kind_label)
119
+ elif part.name == KEY_FIELD:
120
+ out.append(key)
121
+ else:
122
+ out.append(_format(values.get(self.attributes[part.name])))
123
+ return _WHITESPACE.sub(" ", "".join(out)).strip()
124
+
125
+
126
+ def template_problems(kind: Kind) -> list[str]:
127
+ """Why the Kind's template cannot be used; empty when it can (or there is none)."""
128
+ try:
129
+ _resolve(kind)
130
+ except TemplateError as error:
131
+ return [str(error)]
132
+ return []
133
+
134
+
135
+ def kind_template(kind: Kind) -> KindTemplate | None:
136
+ """The resolved template of ``kind``; None without one. Raises `TemplateError`."""
137
+ return _resolve(kind)
138
+
139
+
140
+ def _resolve(kind: Kind) -> KindTemplate | None:
141
+ source = kind.search.embedding_template
142
+ if source is None or not source.strip():
143
+ return None
144
+ template = parse_template(source)
145
+ allow_sensitive = bool(kind.search.embedding_sensitive)
146
+ attributes: dict[str, str] = {}
147
+ for name in template.fields:
148
+ if name in SPECIAL_FIELDS:
149
+ continue
150
+ attribute = kind.attribute_by_api_name(name)
151
+ if attribute is None:
152
+ raise TemplateError(f"{{{name}}} is not an Attribute of Kind {kind.api_name!r}")
153
+ if attribute.is_derived:
154
+ raise TemplateError(f"{{{name}}} is derived; templates read stored values only")
155
+ if attribute.in_vault:
156
+ raise TemplateError(
157
+ f"{{{name}}} is stored in the vault; templates cannot read vault values"
158
+ )
159
+ if attribute.sensitivity and not allow_sensitive:
160
+ raise TemplateError(
161
+ f"{{{name}}} is sensitive ({', '.join(attribute.sensitivity)}); set "
162
+ "search.embedding_sensitive to embed it"
163
+ )
164
+ attributes[name] = attribute.id
165
+ label = kind.display_name or kind.api_name
166
+ fingerprint = hashlib.sha256(
167
+ json.dumps(
168
+ {
169
+ "v": RENDERING_VERSION,
170
+ "kind": kind.id,
171
+ "template": source,
172
+ "attributes": attributes,
173
+ "label": label if KIND_FIELD in template.fields else None,
174
+ },
175
+ sort_keys=True,
176
+ separators=(",", ":"),
177
+ ).encode("utf-8")
178
+ ).hexdigest()
179
+ return KindTemplate(kind.id, template, attributes, label, fingerprint)
180
+
181
+
182
+ def _format(value: JsonValue) -> str:
183
+ if value is None:
184
+ return ""
185
+ if isinstance(value, bool):
186
+ return "true" if value else "false"
187
+ if isinstance(value, str):
188
+ return value
189
+ if isinstance(value, int | float):
190
+ return str(value)
191
+ if isinstance(value, list):
192
+ return ", ".join(text for text in (_format(item) for item in value) if text)
193
+ return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
@@ -0,0 +1,78 @@
1
+ """Stable ids (design 5.1, AC-01): prefixed ULIDs such as ``knd_01JB8Z...``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import secrets
6
+ import time
7
+ from enum import StrEnum
8
+ from typing import Annotated
9
+
10
+ from pydantic import StringConstraints
11
+
12
+ CROCKFORD_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
13
+ ULID_LENGTH = 26
14
+ ULID_REGEX = "[0-7][0-9A-HJKMNP-TV-Z]{25}"
15
+ _TIMESTAMP_BITS = 48
16
+ _RANDOM_BYTES = 10
17
+
18
+
19
+ class IdPrefix(StrEnum):
20
+ KIND = "knd"
21
+ ATTRIBUTE = "att"
22
+ RELATION = "rel"
23
+ TRAIT = "trt"
24
+ OPERATION = "opr"
25
+ DERIVATION = "drv"
26
+ SOURCE = "src"
27
+ PROVENANCE = "prv"
28
+
29
+
30
+ KindId = Annotated[str, StringConstraints(pattern=rf"^knd_{ULID_REGEX}$")]
31
+ AttributeId = Annotated[str, StringConstraints(pattern=rf"^att_{ULID_REGEX}$")]
32
+ RelationId = Annotated[str, StringConstraints(pattern=rf"^rel_{ULID_REGEX}$")]
33
+ TraitId = Annotated[str, StringConstraints(pattern=rf"^trt_{ULID_REGEX}$")]
34
+ OperationId = Annotated[str, StringConstraints(pattern=rf"^opr_{ULID_REGEX}$")]
35
+ DerivationId = Annotated[str, StringConstraints(pattern=rf"^drv_{ULID_REGEX}$")]
36
+ SourceId = Annotated[str, StringConstraints(pattern=rf"^src_{ULID_REGEX}$")]
37
+ ProvenanceId = Annotated[str, StringConstraints(pattern=rf"^prv_{ULID_REGEX}$")]
38
+
39
+
40
+ def new_ulid(*, timestamp_ms: int | None = None, randomness: bytes | None = None) -> str:
41
+ """Mint a ULID. Pass ``timestamp_ms`` and ``randomness`` (10 bytes) for deterministic ids."""
42
+ millis = time.time_ns() // 1_000_000 if timestamp_ms is None else timestamp_ms
43
+ if not 0 <= millis < 2**_TIMESTAMP_BITS:
44
+ raise ValueError("ULID timestamp must fit in 48 bits")
45
+ entropy = secrets.token_bytes(_RANDOM_BYTES) if randomness is None else randomness
46
+ if len(entropy) != _RANDOM_BYTES:
47
+ raise ValueError(f"ULID randomness must be {_RANDOM_BYTES} bytes")
48
+ number = (millis << (8 * _RANDOM_BYTES)) | int.from_bytes(entropy, "big")
49
+ chars = []
50
+ for _ in range(ULID_LENGTH):
51
+ number, digit = divmod(number, 32)
52
+ chars.append(CROCKFORD_ALPHABET[digit])
53
+ return "".join(reversed(chars))
54
+
55
+
56
+ def new_id(
57
+ prefix: IdPrefix, *, timestamp_ms: int | None = None, randomness: bytes | None = None
58
+ ) -> str:
59
+ """Mint a prefixed id, for example ``new_id(IdPrefix.KIND)`` -> ``knd_01JB8Z...``."""
60
+ return f"{prefix.value}_{new_ulid(timestamp_ms=timestamp_ms, randomness=randomness)}"
61
+
62
+
63
+ def is_ulid(text: str) -> bool:
64
+ return (
65
+ len(text) == ULID_LENGTH
66
+ and text[0] in CROCKFORD_ALPHABET[:8]
67
+ and all(ch in CROCKFORD_ALPHABET for ch in text)
68
+ )
69
+
70
+
71
+ def id_prefix(element_id: str) -> IdPrefix | None:
72
+ """The prefix of a well-formed id, or ``None``."""
73
+ prefix, _, rest = element_id.partition("_")
74
+ try:
75
+ parsed = IdPrefix(prefix)
76
+ except ValueError:
77
+ return None
78
+ return parsed if is_ulid(rest) else None
@@ -0,0 +1,85 @@
1
+ """Kind (design 5.3): a type of thing in a Domain."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import StrEnum
6
+ from typing import ClassVar, Self
7
+
8
+ from pydantic import Field, model_validator
9
+
10
+ from lorebase_core.model.attribute import Attribute
11
+ from lorebase_core.model.base import ElementStatus, Model, SchemaElement
12
+ from lorebase_core.model.ids import AttributeId, KindId, TraitId
13
+
14
+
15
+ class BuiltinOperation(StrEnum):
16
+ """Built-in Operations a Kind can enable (design 5.7)."""
17
+
18
+ CREATE = "create"
19
+ UPDATE = "update"
20
+ DELETE = "delete"
21
+
22
+
23
+ class TraitImplementation(Model):
24
+ """A Kind implementing a Trait.
25
+
26
+ ``attribute_map`` maps Trait Attribute ids to Kind Attribute ids.
27
+ """
28
+
29
+ trait_id: TraitId
30
+ attribute_map: dict[AttributeId, AttributeId] = Field(default_factory=dict)
31
+
32
+
33
+ class KindSearch(Model):
34
+ """Search settings of a Kind. Full-text fields come from searchable Attributes.
35
+
36
+ ``embedding_template`` opts the Kind into semantic search: Scribe embeds the text it
37
+ renders for each Record (``lorebase_core.model.embedding``). ``embedding_sensitive`` lets
38
+ the template name sensitive Attributes; unset, they are refused.
39
+ """
40
+
41
+ embedding_template: str | None = None
42
+ embedding_sensitive: bool | None = None
43
+
44
+
45
+ def check_attribute_keys(attributes: dict[str, Attribute], owner: str) -> None:
46
+ for key, attribute in attributes.items():
47
+ if key != attribute.id:
48
+ raise ValueError(
49
+ f"{owner}: attribute map key {key} does not match attribute id {attribute.id}"
50
+ )
51
+
52
+
53
+ class Kind(SchemaElement):
54
+ object_type: ClassVar[str] = "schema.kind"
55
+
56
+ id: KindId
57
+ primary_key: tuple[AttributeId, ...] = ()
58
+ title_attribute: AttributeId | None = None
59
+ attributes: dict[AttributeId, Attribute] = Field(default_factory=dict)
60
+ traits: tuple[TraitImplementation, ...] = ()
61
+ status: ElementStatus = ElementStatus.ACTIVE
62
+ search: KindSearch = Field(default_factory=KindSearch)
63
+ icon: str | None = None
64
+ color: str | None = None
65
+ operations_builtin: tuple[BuiltinOperation, ...] = (
66
+ BuiltinOperation.CREATE,
67
+ BuiltinOperation.UPDATE,
68
+ BuiltinOperation.DELETE,
69
+ )
70
+
71
+ @model_validator(mode="after")
72
+ def _check_keys(self) -> Self:
73
+ check_attribute_keys(self.attributes, f"Kind {self.id}")
74
+ return self
75
+
76
+ @property
77
+ def has_natural_key(self) -> bool:
78
+ """True when Records are keyed by primary key values, False when keys are minted ULIDs."""
79
+ return bool(self.primary_key)
80
+
81
+ def attribute_by_api_name(self, api_name: str) -> Attribute | None:
82
+ for attribute in self.attributes.values():
83
+ if attribute.api_name == api_name:
84
+ return attribute
85
+ return None