ontolith 1.0.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.
- ontolith/__init__.py +47 -0
- ontolith/core/__init__.py +80 -0
- ontolith/core/assertion.py +132 -0
- ontolith/core/clock.py +116 -0
- ontolith/core/embedder.py +141 -0
- ontolith/core/entity.py +34 -0
- ontolith/core/errors.py +159 -0
- ontolith/core/ids.py +146 -0
- ontolith/core/namespace.py +30 -0
- ontolith/core/observability.py +216 -0
- ontolith/govern/__init__.py +66 -0
- ontolith/govern/conflict.py +234 -0
- ontolith/govern/contradiction.py +112 -0
- ontolith/govern/policy.py +886 -0
- ontolith/govern/proposal.py +114 -0
- ontolith/govern/provenance.py +44 -0
- ontolith/identity/__init__.py +45 -0
- ontolith/identity/admin_event.py +57 -0
- ontolith/identity/credential.py +44 -0
- ontolith/identity/ports.py +33 -0
- ontolith/identity/principal.py +67 -0
- ontolith/identity/token_auth.py +52 -0
- ontolith/interfaces/__init__.py +0 -0
- ontolith/interfaces/cli.py +1135 -0
- ontolith/interfaces/graphql.py +1203 -0
- ontolith/interfaces/mcp.py +1046 -0
- ontolith/interfaces/rest.py +1407 -0
- ontolith/observe/__init__.py +0 -0
- ontolith/ontology.py +3614 -0
- ontolith/plugins/__init__.py +40 -0
- ontolith/plugins/manifest.py +79 -0
- ontolith/plugins/ports.py +148 -0
- ontolith/plugins/reference/__init__.py +33 -0
- ontolith/plugins/reference/csv_importer.py +159 -0
- ontolith/plugins/reference/json_exporter.py +56 -0
- ontolith/plugins/reference/rdf_exporter.py +172 -0
- ontolith/plugins/reference/required_fields_validator.py +103 -0
- ontolith/plugins/registry.py +476 -0
- ontolith/plugins/sandbox/__init__.py +18 -0
- ontolith/plugins/sandbox/enforcement.py +417 -0
- ontolith/plugins/sandbox/protocol.py +151 -0
- ontolith/plugins/sandbox/remote_view.py +175 -0
- ontolith/plugins/sandbox/runner.py +769 -0
- ontolith/plugins/sandbox/wire.py +369 -0
- ontolith/plugins/views.py +150 -0
- ontolith/py.typed +0 -0
- ontolith/query/__init__.py +9 -0
- ontolith/query/builder.py +578 -0
- ontolith/schema/__init__.py +58 -0
- ontolith/schema/dsl.py +425 -0
- ontolith/schema/ir.py +228 -0
- ontolith/schema/linkml.py +367 -0
- ontolith/schema/rdf.py +245 -0
- ontolith/store/__init__.py +5 -0
- ontolith/store/base.py +926 -0
- ontolith/store/duckdb/__init__.py +11 -0
- ontolith/store/duckdb/backend.py +2279 -0
- ontolith/store/duckdb/migrations.py +430 -0
- ontolith/store/migrations.py +72 -0
- ontolith/store/sqlite/__init__.py +9 -0
- ontolith/store/sqlite/backend.py +2549 -0
- ontolith/store/sqlite/migrations.py +462 -0
- ontolith-1.0.0.dist-info/METADATA +137 -0
- ontolith-1.0.0.dist-info/RECORD +67 -0
- ontolith-1.0.0.dist-info/WHEEL +4 -0
- ontolith-1.0.0.dist-info/entry_points.txt +8 -0
- ontolith-1.0.0.dist-info/licenses/LICENSE +201 -0
ontolith/__init__.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Ontolith: A Python framework for collaborative knowledge bases.
|
|
2
|
+
|
|
3
|
+
Where humans and AI agents are co-equal authors of a shared, governed ontology.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from ontolith.core import Assertion, Entity, FixedClock, Namespace, SequentialIdProvider
|
|
7
|
+
from ontolith.identity import Principal
|
|
8
|
+
from ontolith.ontology import AsOfView, Ontology
|
|
9
|
+
from ontolith.schema import (
|
|
10
|
+
JSON,
|
|
11
|
+
URI,
|
|
12
|
+
Boolean,
|
|
13
|
+
Concept,
|
|
14
|
+
Date,
|
|
15
|
+
DateTime,
|
|
16
|
+
Float,
|
|
17
|
+
Integer,
|
|
18
|
+
Property,
|
|
19
|
+
Ref,
|
|
20
|
+
Relation,
|
|
21
|
+
Text,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
__version__ = "1.0.0"
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"Ontology",
|
|
28
|
+
"AsOfView",
|
|
29
|
+
"Entity",
|
|
30
|
+
"Assertion",
|
|
31
|
+
"Namespace",
|
|
32
|
+
"Principal",
|
|
33
|
+
"FixedClock",
|
|
34
|
+
"SequentialIdProvider",
|
|
35
|
+
"Concept",
|
|
36
|
+
"Relation",
|
|
37
|
+
"Property",
|
|
38
|
+
"Ref",
|
|
39
|
+
"Text",
|
|
40
|
+
"Integer",
|
|
41
|
+
"Float",
|
|
42
|
+
"Boolean",
|
|
43
|
+
"Date",
|
|
44
|
+
"DateTime",
|
|
45
|
+
"URI",
|
|
46
|
+
"JSON",
|
|
47
|
+
]
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Core abstractions and ports for Ontolith.
|
|
2
|
+
|
|
3
|
+
This module contains the fundamental building blocks:
|
|
4
|
+
- Clock and IdProvider ports for deterministic behavior
|
|
5
|
+
- Embedder port for hybrid retrieval (SPEC §11.3)
|
|
6
|
+
- ObservabilitySink port for metrics/events/structured logs (SPEC §18, ADR-0044)
|
|
7
|
+
- Error taxonomy for stable error handling
|
|
8
|
+
- Entity and Assertion value objects
|
|
9
|
+
|
|
10
|
+
The meta-model/IR/validation this docstring once listed as "Future" now
|
|
11
|
+
lives in `ontolith.schema` (`SchemaIR`, `ConceptDef`/`PropertyDef`/
|
|
12
|
+
`RelationDef`, the class DSL) — not `ontolith.core`, and shipped in M1/M3.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from ontolith.core.assertion import Assertion, AssertionEvent
|
|
16
|
+
from ontolith.core.clock import Clock, FixedClock, SystemClock
|
|
17
|
+
from ontolith.core.embedder import Embedder, HashingEmbedder, LookupEmbedder
|
|
18
|
+
from ontolith.core.entity import Entity
|
|
19
|
+
from ontolith.core.errors import (
|
|
20
|
+
AuthError,
|
|
21
|
+
CapabilityError,
|
|
22
|
+
ConflictError,
|
|
23
|
+
NotFoundError,
|
|
24
|
+
OntolithError,
|
|
25
|
+
PluginError,
|
|
26
|
+
PolicyDenied,
|
|
27
|
+
SchemaError,
|
|
28
|
+
StorageError,
|
|
29
|
+
ValidationError,
|
|
30
|
+
)
|
|
31
|
+
from ontolith.core.ids import (
|
|
32
|
+
FixedIdProvider,
|
|
33
|
+
IdProvider,
|
|
34
|
+
SequentialIdProvider,
|
|
35
|
+
UlidProvider,
|
|
36
|
+
)
|
|
37
|
+
from ontolith.core.namespace import Namespace
|
|
38
|
+
from ontolith.core.observability import (
|
|
39
|
+
NullObservabilitySink,
|
|
40
|
+
ObservabilitySink,
|
|
41
|
+
RecordingObservabilitySink,
|
|
42
|
+
StdlibLoggingSink,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
__all__ = [
|
|
46
|
+
# Clock
|
|
47
|
+
"Clock",
|
|
48
|
+
"SystemClock",
|
|
49
|
+
"FixedClock",
|
|
50
|
+
# IDs
|
|
51
|
+
"IdProvider",
|
|
52
|
+
"UlidProvider",
|
|
53
|
+
"SequentialIdProvider",
|
|
54
|
+
"FixedIdProvider",
|
|
55
|
+
# Embedder
|
|
56
|
+
"Embedder",
|
|
57
|
+
"HashingEmbedder",
|
|
58
|
+
"LookupEmbedder",
|
|
59
|
+
# Observability
|
|
60
|
+
"ObservabilitySink",
|
|
61
|
+
"StdlibLoggingSink",
|
|
62
|
+
"NullObservabilitySink",
|
|
63
|
+
"RecordingObservabilitySink",
|
|
64
|
+
# Domain models
|
|
65
|
+
"Entity",
|
|
66
|
+
"Assertion",
|
|
67
|
+
"AssertionEvent",
|
|
68
|
+
"Namespace",
|
|
69
|
+
# Errors
|
|
70
|
+
"OntolithError",
|
|
71
|
+
"SchemaError",
|
|
72
|
+
"ValidationError",
|
|
73
|
+
"AuthError",
|
|
74
|
+
"CapabilityError",
|
|
75
|
+
"PolicyDenied",
|
|
76
|
+
"ConflictError",
|
|
77
|
+
"NotFoundError",
|
|
78
|
+
"StorageError",
|
|
79
|
+
"PluginError",
|
|
80
|
+
]
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Assertion value object - atomic unit of knowledge.
|
|
2
|
+
|
|
3
|
+
Per SPEC §5.3: Assertions are append-only. Only status, valid_to, and
|
|
4
|
+
supersedes links may be modified after creation.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from typing import Any, Literal
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Assertion(BaseModel):
|
|
14
|
+
"""Atomic unit of knowledge with full provenance.
|
|
15
|
+
|
|
16
|
+
Attributes:
|
|
17
|
+
id: Unique identifier (ULID)
|
|
18
|
+
namespace: Namespace slug
|
|
19
|
+
subject: Entity ID this assertion is about
|
|
20
|
+
predicate: Qualified property/relation name (e.g., "Person.name")
|
|
21
|
+
value_kind: Whether value is a literal or entity reference
|
|
22
|
+
value_type: Type of literal value (if value_kind is "literal")
|
|
23
|
+
value: The actual value (literal or entity ID)
|
|
24
|
+
author: Principal ID who made this assertion
|
|
25
|
+
acting_as: Principal ID if delegated (optional)
|
|
26
|
+
source: Source of information (URI or description)
|
|
27
|
+
confidence: Author's stated belief (0.0-1.0)
|
|
28
|
+
rationale: Why this assertion was made
|
|
29
|
+
model: Model family+version for AI authors
|
|
30
|
+
asserted_at: When we learned this fact
|
|
31
|
+
valid_from: When the fact became/becomes true (defaults to asserted_at)
|
|
32
|
+
valid_to: When the fact stopped being true (NULL = still valid)
|
|
33
|
+
status: Current lifecycle state
|
|
34
|
+
proposal_id: Proposal that introduced this assertion
|
|
35
|
+
supersedes: Previous assertion ID if this supersedes another
|
|
36
|
+
metadata: Open JSON blob for future evolution
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
# Core identity
|
|
40
|
+
id: str
|
|
41
|
+
namespace: str
|
|
42
|
+
subject: str
|
|
43
|
+
predicate: str
|
|
44
|
+
|
|
45
|
+
# Value
|
|
46
|
+
value_kind: Literal["literal", "ref"]
|
|
47
|
+
value_type: str | None = None # Required when value_kind="literal"
|
|
48
|
+
value: str # Literal value or entity ID
|
|
49
|
+
|
|
50
|
+
# Provenance
|
|
51
|
+
author: str
|
|
52
|
+
acting_as: str | None = None
|
|
53
|
+
source: str | None = None
|
|
54
|
+
confidence: float | None = Field(None, ge=0.0, le=1.0)
|
|
55
|
+
rationale: str | None = None
|
|
56
|
+
model: str | None = None # Required for AI authors
|
|
57
|
+
|
|
58
|
+
# Temporal
|
|
59
|
+
asserted_at: datetime
|
|
60
|
+
valid_from: datetime | None = None # Defaults to asserted_at if not provided
|
|
61
|
+
valid_to: datetime | None = None
|
|
62
|
+
|
|
63
|
+
# Lifecycle
|
|
64
|
+
status: Literal["active", "superseded", "retracted", "flagged"] = "active"
|
|
65
|
+
proposal_id: str | None = None
|
|
66
|
+
supersedes: str | None = None
|
|
67
|
+
|
|
68
|
+
# Future evolution
|
|
69
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
70
|
+
|
|
71
|
+
@model_validator(mode="before")
|
|
72
|
+
@classmethod
|
|
73
|
+
def set_defaults_and_validate(cls, values: dict[str, Any]) -> dict[str, Any]:
|
|
74
|
+
"""Set valid_from default and validate value_type based on value_kind."""
|
|
75
|
+
# Default valid_from to asserted_at (SPEC §5.3)
|
|
76
|
+
if "valid_from" not in values or values["valid_from"] is None:
|
|
77
|
+
values["valid_from"] = values.get("asserted_at")
|
|
78
|
+
|
|
79
|
+
# Validate value_type based on value_kind
|
|
80
|
+
value_kind = values.get("value_kind")
|
|
81
|
+
value_type = values.get("value_type")
|
|
82
|
+
|
|
83
|
+
if value_kind == "literal" and value_type is None:
|
|
84
|
+
raise ValueError("value_type is required when value_kind is 'literal'")
|
|
85
|
+
if value_kind == "ref" and value_type is not None:
|
|
86
|
+
raise ValueError("value_type must be None when value_kind is 'ref'")
|
|
87
|
+
|
|
88
|
+
return values
|
|
89
|
+
|
|
90
|
+
model_config = ConfigDict(
|
|
91
|
+
frozen=True, # Immutable by default
|
|
92
|
+
# Only status, valid_to, supersedes can be "mutated" via copy(update={...})
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class AssertionEvent(BaseModel):
|
|
97
|
+
"""Append-only audit record of an assertion status mutation.
|
|
98
|
+
|
|
99
|
+
Assertions themselves are append-only (only status/valid_to/supersedes
|
|
100
|
+
mutate in place) — this event log makes each such mutation independently
|
|
101
|
+
attributable and timestamped, since the assertion row itself is
|
|
102
|
+
overwritten in place and doesn't retain who caused a given transition
|
|
103
|
+
or when it happened (only its current status).
|
|
104
|
+
|
|
105
|
+
Attributes:
|
|
106
|
+
id: Unique event ID (ULID)
|
|
107
|
+
assertion_id: Assertion this event was recorded against
|
|
108
|
+
actor: Principal ID accountable for the transition — the author of
|
|
109
|
+
the assertion that triggered supersession/contradiction during
|
|
110
|
+
conflict routing, or the principal who explicitly retracted/
|
|
111
|
+
resolved it
|
|
112
|
+
action: Which status transition this event records
|
|
113
|
+
at: When the transition occurred
|
|
114
|
+
successor_id: For action="superseded", the id of the assertion that
|
|
115
|
+
caused this supersession. Assertion.supersedes only records the
|
|
116
|
+
first predecessor when one incoming assertion supersedes several
|
|
117
|
+
concurrently-overlapping ones (KI-008) — querying events by
|
|
118
|
+
successor_id recovers the full predecessor set. None for other
|
|
119
|
+
actions.
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
id: str
|
|
123
|
+
assertion_id: str
|
|
124
|
+
actor: str
|
|
125
|
+
action: Literal["superseded", "flagged", "retracted", "reactivated"]
|
|
126
|
+
at: datetime
|
|
127
|
+
successor_id: str | None = None
|
|
128
|
+
|
|
129
|
+
model_config = ConfigDict(frozen=True)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
__all__ = ["Assertion", "AssertionEvent"]
|
ontolith/core/clock.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Clock abstraction for deterministic time handling.
|
|
2
|
+
|
|
3
|
+
The Clock port ensures bitemporal logic is reproducible in tests by injecting
|
|
4
|
+
time rather than using datetime.now() directly in domain logic.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from abc import ABC, abstractmethod
|
|
8
|
+
from datetime import UTC, datetime, timedelta
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Clock(ABC):
|
|
12
|
+
"""Abstract clock for deterministic time handling."""
|
|
13
|
+
|
|
14
|
+
@abstractmethod
|
|
15
|
+
def now(self) -> datetime:
|
|
16
|
+
"""Return the current time.
|
|
17
|
+
|
|
18
|
+
Returns:
|
|
19
|
+
Current datetime in UTC.
|
|
20
|
+
"""
|
|
21
|
+
...
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class SystemClock(Clock):
|
|
25
|
+
"""Production clock using system time."""
|
|
26
|
+
|
|
27
|
+
def now(self) -> datetime:
|
|
28
|
+
"""Return the current system time in UTC.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
Current datetime in UTC timezone.
|
|
32
|
+
"""
|
|
33
|
+
return datetime.now(UTC)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class FixedClock(Clock):
|
|
37
|
+
"""Test clock that returns a fixed time.
|
|
38
|
+
|
|
39
|
+
Useful for testing time-sensitive logic without flakiness.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
fixed_time: The time to always return, or current time if None.
|
|
43
|
+
|
|
44
|
+
Example:
|
|
45
|
+
>>> clock = FixedClock("2025-01-01T00:00:00Z")
|
|
46
|
+
>>> clock.now()
|
|
47
|
+
datetime.datetime(2025, 1, 1, 0, 0, tzinfo=datetime.timezone.utc)
|
|
48
|
+
>>> clock.advance(days=1)
|
|
49
|
+
>>> clock.now()
|
|
50
|
+
datetime.datetime(2025, 1, 2, 0, 0, tzinfo=datetime.timezone.utc)
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(self, fixed_time: str | datetime | None = None) -> None:
|
|
54
|
+
"""Initialize with a fixed time.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
fixed_time: ISO-8601 string, datetime, or None for current time.
|
|
58
|
+
"""
|
|
59
|
+
if fixed_time is None:
|
|
60
|
+
self._time = datetime.now(UTC)
|
|
61
|
+
elif isinstance(fixed_time, str):
|
|
62
|
+
self._time = datetime.fromisoformat(fixed_time.replace("Z", "+00:00"))
|
|
63
|
+
else:
|
|
64
|
+
self._time = fixed_time
|
|
65
|
+
|
|
66
|
+
# Ensure timezone-aware
|
|
67
|
+
if self._time.tzinfo is None:
|
|
68
|
+
self._time = self._time.replace(tzinfo=UTC)
|
|
69
|
+
|
|
70
|
+
def now(self) -> datetime:
|
|
71
|
+
"""Return the fixed time.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
The configured fixed datetime.
|
|
75
|
+
"""
|
|
76
|
+
return self._time
|
|
77
|
+
|
|
78
|
+
def set(self, time: str | datetime) -> None:
|
|
79
|
+
"""Set the clock to a new time.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
time: ISO-8601 string or datetime to set.
|
|
83
|
+
"""
|
|
84
|
+
if isinstance(time, str):
|
|
85
|
+
self._time = datetime.fromisoformat(time.replace("Z", "+00:00"))
|
|
86
|
+
else:
|
|
87
|
+
self._time = time
|
|
88
|
+
|
|
89
|
+
if self._time.tzinfo is None:
|
|
90
|
+
self._time = self._time.replace(tzinfo=UTC)
|
|
91
|
+
|
|
92
|
+
def advance(
|
|
93
|
+
self,
|
|
94
|
+
*,
|
|
95
|
+
days: int = 0,
|
|
96
|
+
hours: int = 0,
|
|
97
|
+
minutes: int = 0,
|
|
98
|
+
seconds: int = 0,
|
|
99
|
+
) -> None:
|
|
100
|
+
"""Advance the clock by the specified duration.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
days: Number of days to advance.
|
|
104
|
+
hours: Number of hours to advance.
|
|
105
|
+
minutes: Number of minutes to advance.
|
|
106
|
+
seconds: Number of seconds to advance.
|
|
107
|
+
"""
|
|
108
|
+
self._time += timedelta(
|
|
109
|
+
days=days,
|
|
110
|
+
hours=hours,
|
|
111
|
+
minutes=minutes,
|
|
112
|
+
seconds=seconds,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
__all__ = ["Clock", "SystemClock", "FixedClock"]
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Embedder port for hybrid retrieval (SPEC §11.3, §14).
|
|
2
|
+
|
|
3
|
+
The Embedder port converts text into vectors for semantic search over the
|
|
4
|
+
vector store (StorageBackend.vector_upsert/vector_search). It is a pure
|
|
5
|
+
text-to-vector transform with no knowledge of KB state — the same shape as
|
|
6
|
+
Clock and IdProvider, not a capability-scoped plugin actor like Importer or
|
|
7
|
+
Exporter (see plugins/ports.py's docstring for that distinction).
|
|
8
|
+
|
|
9
|
+
Every Embedder implementation MUST return L2-unit-normalized vectors: this
|
|
10
|
+
makes ranking by ascending L2 distance equivalent to ranking by descending
|
|
11
|
+
cosine similarity (for unit vectors u, v: ||u-v||^2 = 2 - 2*cos_sim(u, v)),
|
|
12
|
+
which lets StorageBackend.vector_search use one distance metric consistently
|
|
13
|
+
across backends without an explicit similarity-function parameter.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import hashlib
|
|
17
|
+
import math
|
|
18
|
+
import re
|
|
19
|
+
from typing import Protocol
|
|
20
|
+
|
|
21
|
+
_TOKEN_RE = re.compile(r"\w+")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Embedder(Protocol):
|
|
25
|
+
"""Converts text into L2-unit-normalized embedding vectors."""
|
|
26
|
+
|
|
27
|
+
name: str
|
|
28
|
+
dim: int
|
|
29
|
+
|
|
30
|
+
def embed(self, texts: list[str]) -> list[list[float]]:
|
|
31
|
+
"""Embed a batch of texts.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
texts: Texts to embed.
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
One L2-unit-normalized vector of length `dim` per input text, in
|
|
38
|
+
the same order. A text with no extractable signal (empty or
|
|
39
|
+
whitespace-only) yields the zero vector, not an error.
|
|
40
|
+
"""
|
|
41
|
+
...
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class HashingEmbedder:
|
|
45
|
+
"""Deterministic, dependency-free default Embedder.
|
|
46
|
+
|
|
47
|
+
Implements the feature-hashing trick (Weinberger et al., 2009): each
|
|
48
|
+
token hashes to a bucket index and a sign, avoiding the need for a
|
|
49
|
+
pre-built vocabulary or any numeric library. This is a placeholder for
|
|
50
|
+
real semantic quality, not a substitute for a model-backed Embedder —
|
|
51
|
+
it guarantees deterministic, reproducible vectors for a given text, not
|
|
52
|
+
meaningful semantic similarity between unrelated texts.
|
|
53
|
+
|
|
54
|
+
Uses hashlib.sha256, not Python's built-in hash(), because str hashing
|
|
55
|
+
is randomly salted per process (PYTHONHASHSEED) and would break
|
|
56
|
+
determinism across runs.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
name = "hashing-embedder"
|
|
60
|
+
|
|
61
|
+
def __init__(self, dim: int = 256) -> None:
|
|
62
|
+
"""Initialize with a fixed output dimensionality.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
dim: Length of each output vector.
|
|
66
|
+
"""
|
|
67
|
+
self.dim = dim
|
|
68
|
+
|
|
69
|
+
def embed(self, texts: list[str]) -> list[list[float]]:
|
|
70
|
+
"""Embed a batch of texts via feature hashing.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
texts: Texts to embed.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
One L2-unit-normalized vector of length `dim` per input text.
|
|
77
|
+
"""
|
|
78
|
+
return [self._embed_one(text) for text in texts]
|
|
79
|
+
|
|
80
|
+
def _embed_one(self, text: str) -> list[float]:
|
|
81
|
+
"""Embed a single text via feature hashing."""
|
|
82
|
+
vec = [0.0] * self.dim
|
|
83
|
+
for token in _TOKEN_RE.findall(text.lower()):
|
|
84
|
+
digest = hashlib.sha256(token.encode("utf-8")).digest()
|
|
85
|
+
bucket = int.from_bytes(digest[:8], "big") % self.dim
|
|
86
|
+
sign = 1.0 if digest[8] & 1 else -1.0
|
|
87
|
+
vec[bucket] += sign
|
|
88
|
+
|
|
89
|
+
norm = math.sqrt(sum(v * v for v in vec))
|
|
90
|
+
if norm == 0.0:
|
|
91
|
+
return vec
|
|
92
|
+
return [v / norm for v in vec]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class LookupEmbedder:
|
|
96
|
+
"""Deterministic test double backed by an explicit text-to-vector map.
|
|
97
|
+
|
|
98
|
+
Unlike HashingEmbedder, output is fully predictable — use this in tests
|
|
99
|
+
that need to assert exact rank order from semantic search.
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
name = "lookup-embedder"
|
|
103
|
+
|
|
104
|
+
def __init__(
|
|
105
|
+
self, mapping: dict[str, list[float]], dim: int, default: list[float] | None = None
|
|
106
|
+
) -> None:
|
|
107
|
+
"""Initialize with an explicit text-to-vector mapping.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
mapping: Exact text to vector lookup.
|
|
111
|
+
dim: Length of vectors in `mapping` and `default`.
|
|
112
|
+
default: Vector to return for unmapped texts, or None to raise.
|
|
113
|
+
"""
|
|
114
|
+
self._mapping = mapping
|
|
115
|
+
self.dim = dim
|
|
116
|
+
self._default = default
|
|
117
|
+
|
|
118
|
+
def embed(self, texts: list[str]) -> list[list[float]]:
|
|
119
|
+
"""Look up each text's vector.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
texts: Texts to look up.
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
The mapped (or default) vector per input text.
|
|
126
|
+
|
|
127
|
+
Raises:
|
|
128
|
+
KeyError: A text is not in `mapping` and no `default` was given.
|
|
129
|
+
"""
|
|
130
|
+
result = []
|
|
131
|
+
for text in texts:
|
|
132
|
+
if text in self._mapping:
|
|
133
|
+
result.append(self._mapping[text])
|
|
134
|
+
elif self._default is not None:
|
|
135
|
+
result.append(self._default)
|
|
136
|
+
else:
|
|
137
|
+
raise KeyError(f"LookupEmbedder: no vector mapped for {text!r}")
|
|
138
|
+
return result
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
__all__ = ["Embedder", "HashingEmbedder", "LookupEmbedder"]
|
ontolith/core/entity.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Entity value object - concrete individual of a concept.
|
|
2
|
+
|
|
3
|
+
Per SPEC §5.2: Entities carry no attribute values directly - all attributes
|
|
4
|
+
are Assertions about the entity.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, ConfigDict
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Entity(BaseModel):
|
|
13
|
+
"""Concrete individual of a concept.
|
|
14
|
+
|
|
15
|
+
Attributes:
|
|
16
|
+
id: Unique identifier (ULID)
|
|
17
|
+
namespace: Namespace slug
|
|
18
|
+
concept: Concept name (e.g., "Person", "Organization")
|
|
19
|
+
natural_key: Optional unique key within (namespace, concept)
|
|
20
|
+
created_at: When this entity was created
|
|
21
|
+
created_by: Principal ID who created this entity
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
id: str
|
|
25
|
+
namespace: str
|
|
26
|
+
concept: str
|
|
27
|
+
natural_key: str | None = None
|
|
28
|
+
created_at: datetime
|
|
29
|
+
created_by: str
|
|
30
|
+
|
|
31
|
+
model_config = ConfigDict(frozen=True) # Immutable
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
__all__ = ["Entity"]
|