generic-ml-cache-core 0.2.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 (99) hide show
  1. generic_ml_cache_core/__init__.py +64 -0
  2. generic_ml_cache_core/adapter/__init__.py +1 -0
  3. generic_ml_cache_core/adapter/inbound/__init__.py +1 -0
  4. generic_ml_cache_core/adapter/inbound/composition.py +96 -0
  5. generic_ml_cache_core/adapter/out/__init__.py +1 -0
  6. generic_ml_cache_core/adapter/out/api/__init__.py +1 -0
  7. generic_ml_cache_core/adapter/out/api/stub_api_client_adapter.py +30 -0
  8. generic_ml_cache_core/adapter/out/client/__init__.py +28 -0
  9. generic_ml_cache_core/adapter/out/client/claude.py +214 -0
  10. generic_ml_cache_core/adapter/out/client/codex.py +171 -0
  11. generic_ml_cache_core/adapter/out/client/cursor.py +208 -0
  12. generic_ml_cache_core/adapter/out/client/discover.py +121 -0
  13. generic_ml_cache_core/adapter/out/client/isolation.py +396 -0
  14. generic_ml_cache_core/adapter/out/client/local_client_runner.py +54 -0
  15. generic_ml_cache_core/adapter/out/client/passthrough_client_runner.py +47 -0
  16. generic_ml_cache_core/adapter/out/client/prime_directive.py +53 -0
  17. generic_ml_cache_core/adapter/out/client/registry.py +34 -0
  18. generic_ml_cache_core/adapter/out/clock/__init__.py +1 -0
  19. generic_ml_cache_core/adapter/out/clock/system_clock.py +16 -0
  20. generic_ml_cache_core/adapter/out/fingerprint/__init__.py +1 -0
  21. generic_ml_cache_core/adapter/out/fingerprint/filesystem_file_fingerprint.py +30 -0
  22. generic_ml_cache_core/adapter/out/metrics/__init__.py +1 -0
  23. generic_ml_cache_core/adapter/out/metrics/access_registry.py +147 -0
  24. generic_ml_cache_core/adapter/out/metrics/journal_metrics.py +45 -0
  25. generic_ml_cache_core/adapter/out/persistence/__init__.py +1 -0
  26. generic_ml_cache_core/adapter/out/persistence/call_identity_serialization.py +100 -0
  27. generic_ml_cache_core/adapter/out/persistence/in_memory_execution_repository.py +69 -0
  28. generic_ml_cache_core/adapter/out/persistence/sqlite_execution_repository.py +398 -0
  29. generic_ml_cache_core/adapter/out/storage/__init__.py +1 -0
  30. generic_ml_cache_core/adapter/out/storage/filesystem_blob_store.py +47 -0
  31. generic_ml_cache_core/application/__init__.py +1 -0
  32. generic_ml_cache_core/application/domain/__init__.py +1 -0
  33. generic_ml_cache_core/application/domain/model/__init__.py +1 -0
  34. generic_ml_cache_core/application/domain/model/client_status.py +17 -0
  35. generic_ml_cache_core/application/domain/model/execution/__init__.py +1 -0
  36. generic_ml_cache_core/application/domain/model/execution/artifact.py +78 -0
  37. generic_ml_cache_core/application/domain/model/execution/execution_failure.py +32 -0
  38. generic_ml_cache_core/application/domain/model/execution/execution_kind.py +26 -0
  39. generic_ml_cache_core/application/domain/model/execution/execution_state.py +21 -0
  40. generic_ml_cache_core/application/domain/model/execution/ml_execution.py +41 -0
  41. generic_ml_cache_core/application/domain/model/identity/__init__.py +1 -0
  42. generic_ml_cache_core/application/domain/model/identity/api_call_identity.py +36 -0
  43. generic_ml_cache_core/application/domain/model/identity/call_identity.py +25 -0
  44. generic_ml_cache_core/application/domain/model/identity/managed_call_identity.py +54 -0
  45. generic_ml_cache_core/application/domain/model/identity/passthrough_call_identity.py +35 -0
  46. generic_ml_cache_core/application/domain/model/model_info.py +20 -0
  47. generic_ml_cache_core/application/domain/model/model_listing.py +29 -0
  48. generic_ml_cache_core/application/domain/model/parsed_output.py +23 -0
  49. generic_ml_cache_core/application/domain/model/probe/__init__.py +1 -0
  50. generic_ml_cache_core/application/domain/model/probe/probe_report.py +26 -0
  51. generic_ml_cache_core/application/domain/model/probe/probe_status.py +13 -0
  52. generic_ml_cache_core/application/domain/model/run/__init__.py +1 -0
  53. generic_ml_cache_core/application/domain/model/run/cache_mode.py +21 -0
  54. generic_ml_cache_core/application/domain/model/run/client_run_request.py +35 -0
  55. generic_ml_cache_core/application/domain/model/run/client_run_result.py +65 -0
  56. generic_ml_cache_core/application/domain/model/run/message.py +20 -0
  57. generic_ml_cache_core/application/domain/model/usage/__init__.py +1 -0
  58. generic_ml_cache_core/application/domain/model/usage/token_usage.py +53 -0
  59. generic_ml_cache_core/application/domain/model/usage/usage.py +108 -0
  60. generic_ml_cache_core/application/domain/service/__init__.py +1 -0
  61. generic_ml_cache_core/application/domain/service/cacheability.py +19 -0
  62. generic_ml_cache_core/application/domain/service/message_fingerprinting.py +25 -0
  63. generic_ml_cache_core/application/port/__init__.py +1 -0
  64. generic_ml_cache_core/application/port/inbound/__init__.py +1 -0
  65. generic_ml_cache_core/application/port/inbound/probe_command.py +35 -0
  66. generic_ml_cache_core/application/port/inbound/probe_use_case.py +19 -0
  67. generic_ml_cache_core/application/port/inbound/run_api_execution_command.py +40 -0
  68. generic_ml_cache_core/application/port/inbound/run_api_execution_use_case.py +20 -0
  69. generic_ml_cache_core/application/port/inbound/run_managed_local_execution_command.py +48 -0
  70. generic_ml_cache_core/application/port/inbound/run_managed_local_execution_use_case.py +25 -0
  71. generic_ml_cache_core/application/port/inbound/run_passthrough_execution_command.py +35 -0
  72. generic_ml_cache_core/application/port/inbound/run_passthrough_execution_use_case.py +20 -0
  73. generic_ml_cache_core/application/port/out/__init__.py +1 -0
  74. generic_ml_cache_core/application/port/out/api_client_port.py +26 -0
  75. generic_ml_cache_core/application/port/out/base.py +272 -0
  76. generic_ml_cache_core/application/port/out/blob_store_port.py +37 -0
  77. generic_ml_cache_core/application/port/out/client_runner_port.py +26 -0
  78. generic_ml_cache_core/application/port/out/clock_port.py +22 -0
  79. generic_ml_cache_core/application/port/out/execution_repository_port.py +40 -0
  80. generic_ml_cache_core/application/port/out/file_fingerprint_port.py +25 -0
  81. generic_ml_cache_core/application/port/out/metrics_port.py +54 -0
  82. generic_ml_cache_core/application/port/out/passthrough_runner_port.py +25 -0
  83. generic_ml_cache_core/application/usecase/__init__.py +1 -0
  84. generic_ml_cache_core/application/usecase/cached_ml_execution_service.py +198 -0
  85. generic_ml_cache_core/application/usecase/call_identity_building.py +60 -0
  86. generic_ml_cache_core/application/usecase/journal_events.py +19 -0
  87. generic_ml_cache_core/application/usecase/probe_service.py +44 -0
  88. generic_ml_cache_core/application/usecase/run_api_execution_service.py +69 -0
  89. generic_ml_cache_core/application/usecase/run_managed_local_execution_service.py +84 -0
  90. generic_ml_cache_core/application/usecase/run_passthrough_execution_service.py +67 -0
  91. generic_ml_cache_core/common/__init__.py +1 -0
  92. generic_ml_cache_core/common/checksum.py +82 -0
  93. generic_ml_cache_core/common/errors.py +76 -0
  94. generic_ml_cache_core/stream.py +65 -0
  95. generic_ml_cache_core-0.2.0.dist-info/METADATA +104 -0
  96. generic_ml_cache_core-0.2.0.dist-info/RECORD +99 -0
  97. generic_ml_cache_core-0.2.0.dist-info/WHEEL +4 -0
  98. generic_ml_cache_core-0.2.0.dist-info/licenses/LICENSE +201 -0
  99. generic_ml_cache_core-0.2.0.dist-info/licenses/NOTICE +8 -0
@@ -0,0 +1,147 @@
1
+ # SPDX-FileCopyrightText: 2026 Daniel Slobozian
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """The access registry: a side log of cache access events for observability.
4
+
5
+ It is **non-load-bearing by construction** -- it records *that* a hit / miss /
6
+ record / eviction happened, for `stats` and `prune` to read, but it never gates
7
+ correctness. Every operation swallows its own errors: if the database is missing,
8
+ locked, unwritable, or corrupt, the cache still resolves exactly as it would
9
+ without it. It is deliberately separate from the executions, which stay pure and
10
+ immutable -- no access counters are ever written back into a recording.
11
+
12
+ Stored in the store directory as ``registry.sqlite3`` (stdlib ``sqlite3`` only;
13
+ no third-party dependency). It carries no integrity/checksum role: a checksum kept
14
+ beside the data it guards, in a folder the user can write, protects nothing a
15
+ determined editor couldn't also rewrite -- so we don't pretend to.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import sqlite3
21
+ from datetime import datetime, timezone
22
+ from pathlib import Path
23
+ from typing import Dict, Optional
24
+
25
+ # The access events. A resolve emits exactly one of HIT / MISS / RECORD
26
+ # (passthrough calls are outside cache accounting and emit nothing).
27
+ HIT = "hit"
28
+ MISS = "miss"
29
+ RECORD = "record"
30
+
31
+ _DB_NAME = "registry.sqlite3"
32
+ _SCHEMA = """
33
+ CREATE TABLE IF NOT EXISTS access_events (
34
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
35
+ ts TEXT NOT NULL,
36
+ event TEXT NOT NULL,
37
+ match_key TEXT,
38
+ client TEXT,
39
+ model TEXT,
40
+ effort TEXT
41
+ )
42
+ """
43
+
44
+
45
+ class AccessRegistry:
46
+ """A best-effort SQLite log of access events, living beside the executions."""
47
+
48
+ def __init__(self, root: Path) -> None:
49
+ self._path = Path(root) / _DB_NAME
50
+
51
+ def _connect(self) -> sqlite3.Connection:
52
+ self._path.parent.mkdir(parents=True, exist_ok=True)
53
+ conn = sqlite3.connect(self._path)
54
+ conn.execute(_SCHEMA)
55
+ return conn
56
+
57
+ def record(
58
+ self,
59
+ event: str,
60
+ *,
61
+ match_key: Optional[str],
62
+ client: str,
63
+ model: str,
64
+ effort: str,
65
+ ) -> None:
66
+ """Append one access event. Never raises -- failures are swallowed so the
67
+ cache is never affected by the registry being unavailable."""
68
+ try:
69
+ conn = self._connect()
70
+ try:
71
+ conn.execute(
72
+ "INSERT INTO access_events (ts, event, match_key, client, model, effort) "
73
+ "VALUES (?, ?, ?, ?, ?, ?)",
74
+ (
75
+ datetime.now(timezone.utc).isoformat(timespec="seconds"),
76
+ event,
77
+ match_key,
78
+ client,
79
+ model,
80
+ effort,
81
+ ),
82
+ )
83
+ conn.commit()
84
+ finally:
85
+ conn.close()
86
+ except Exception:
87
+ # Non-load-bearing: observability must never break the cache.
88
+ pass
89
+
90
+ def hit_counts_by_key(self) -> Dict[str, int]:
91
+ """Return {match_key: number-of-hits} across all recorded HIT events
92
+ ({} if unavailable).
93
+
94
+ A hit is a real call that was *not* made because a stored execution answered it,
95
+ so multiplying an execution's recorded usage by its hit count is exactly the
96
+ usage that hit saved. Best-effort like everything here: never raises.
97
+ """
98
+ try:
99
+ conn = self._connect()
100
+ try:
101
+ rows = conn.execute(
102
+ "SELECT match_key, COUNT(*) FROM access_events "
103
+ "WHERE event = ? AND match_key IS NOT NULL GROUP BY match_key",
104
+ (HIT,),
105
+ ).fetchall()
106
+ return {key: int(count) for key, count in rows}
107
+ finally:
108
+ conn.close()
109
+ except Exception:
110
+ return {}
111
+
112
+ def event_counts(self) -> Dict[str, int]:
113
+ """Return {event: count} across all recorded events ({} if unavailable)."""
114
+ try:
115
+ conn = self._connect()
116
+ try:
117
+ rows = conn.execute(
118
+ "SELECT event, COUNT(*) FROM access_events GROUP BY event"
119
+ ).fetchall()
120
+ return {event: count for event, count in rows}
121
+ finally:
122
+ conn.close()
123
+ except Exception:
124
+ return {}
125
+
126
+ def last_access(self) -> Dict[str, float]:
127
+ """Return {match_key: latest-event epoch seconds} for LRU eviction ordering
128
+ ({} if unavailable). An execution absent here has never been seen by the
129
+ registry; the caller falls back to file age for it."""
130
+ try:
131
+ conn = self._connect()
132
+ try:
133
+ rows = conn.execute(
134
+ "SELECT match_key, MAX(ts) FROM access_events "
135
+ "WHERE match_key IS NOT NULL GROUP BY match_key"
136
+ ).fetchall()
137
+ finally:
138
+ conn.close()
139
+ except Exception:
140
+ return {}
141
+ out: Dict[str, float] = {}
142
+ for key, ts in rows:
143
+ try:
144
+ out[key] = datetime.fromisoformat(ts).timestamp()
145
+ except Exception:
146
+ pass
147
+ return out
@@ -0,0 +1,45 @@
1
+ # SPDX-FileCopyrightText: 2026 Daniel Slobozian
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """JournalMetrics: the MetricsPort over the SQLite access registry."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from typing import Dict, Optional
8
+
9
+ from generic_ml_cache_core.adapter.out.metrics.access_registry import AccessRegistry
10
+ from generic_ml_cache_core.application.port.out.metrics_port import MetricsPort
11
+
12
+
13
+ class JournalMetrics(MetricsPort):
14
+ """Implements the journal over the existing best-effort access registry.
15
+
16
+ Non-load-bearing by construction: the registry swallows its own errors, so
17
+ ``record_event`` never raises and the projections return empty on failure —
18
+ observability never breaks an execution. This adapter only maps the port's
19
+ ``execution_key`` onto the registry's ``match_key``.
20
+ """
21
+
22
+ def __init__(self, registry: AccessRegistry) -> None:
23
+ self._registry = registry
24
+
25
+ def record_event(
26
+ self,
27
+ event: str,
28
+ *,
29
+ execution_key: Optional[str],
30
+ client: str,
31
+ model: str,
32
+ effort: str,
33
+ ) -> None:
34
+ self._registry.record(
35
+ event, match_key=execution_key, client=client, model=model, effort=effort
36
+ )
37
+
38
+ def hit_counts_by_key(self) -> Dict[str, int]:
39
+ return self._registry.hit_counts_by_key()
40
+
41
+ def event_counts(self) -> Dict[str, int]:
42
+ return self._registry.event_counts()
43
+
44
+ def last_access(self) -> Dict[str, float]:
45
+ return self._registry.last_access()
@@ -0,0 +1 @@
1
+ """Hexagonal layer package."""
@@ -0,0 +1,100 @@
1
+ # SPDX-FileCopyrightText: 2026 Daniel Slobozian
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Serialize a polymorphic CallIdentity for the SQLite repository.
4
+
5
+ The hybrid persistence (domain-model §3): the queryable fields (kind, client,
6
+ model, effort) become real columns; the divergent/opaque fields ride in a JSON
7
+ column. This pair maps each CallIdentity subclass to that row shape and back. It
8
+ lives in the adapter — the domain identities know nothing about the database.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ from dataclasses import dataclass
15
+
16
+ from generic_ml_cache_core.application.domain.model.identity.api_call_identity import (
17
+ ApiCallIdentity,
18
+ )
19
+ from generic_ml_cache_core.application.domain.model.identity.call_identity import CallIdentity
20
+ from generic_ml_cache_core.application.domain.model.execution.execution_kind import ExecutionKind
21
+ from generic_ml_cache_core.application.domain.model.identity.managed_call_identity import (
22
+ ManagedCallIdentity,
23
+ )
24
+ from generic_ml_cache_core.application.domain.model.identity.passthrough_call_identity import (
25
+ PassthroughCallIdentity,
26
+ )
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class SerializedIdentity:
31
+ """The row shape: denormalized query columns + the serialized remainder."""
32
+
33
+ kind: str
34
+ client: str
35
+ model: str
36
+ effort: str
37
+ identity_json: str
38
+
39
+
40
+ def serialize_identity(identity: CallIdentity) -> SerializedIdentity:
41
+ if isinstance(identity, ManagedCallIdentity):
42
+ return SerializedIdentity(
43
+ kind=ExecutionKind.LOCAL_MANAGED.value,
44
+ client=identity.client,
45
+ model=identity.model,
46
+ effort=identity.effort,
47
+ identity_json=json.dumps(
48
+ {
49
+ "context_fingerprint": identity.context_fingerprint,
50
+ "prompt_fingerprint": identity.prompt_fingerprint,
51
+ "input_file_fingerprints": identity.input_file_fingerprints,
52
+ "client_args_fingerprint": identity.client_args_fingerprint,
53
+ "grants": sorted(identity.grants),
54
+ }
55
+ ),
56
+ )
57
+ if isinstance(identity, PassthroughCallIdentity):
58
+ return SerializedIdentity(
59
+ kind=ExecutionKind.LOCAL_PASSTHROUGH.value,
60
+ client=identity.client,
61
+ model="",
62
+ effort="",
63
+ identity_json=json.dumps({"native_args_fingerprint": identity.native_args_fingerprint}),
64
+ )
65
+ if isinstance(identity, ApiCallIdentity):
66
+ return SerializedIdentity(
67
+ kind=ExecutionKind.API.value,
68
+ client=identity.provider,
69
+ model=identity.model,
70
+ effort="",
71
+ identity_json=json.dumps({"messages_fingerprint": identity.messages_fingerprint}),
72
+ )
73
+ raise ValueError(f"cannot serialize unknown call identity type: {type(identity).__name__}")
74
+
75
+
76
+ def deserialize_identity(serialized: SerializedIdentity) -> CallIdentity:
77
+ fields = json.loads(serialized.identity_json)
78
+ if serialized.kind == ExecutionKind.LOCAL_MANAGED.value:
79
+ return ManagedCallIdentity(
80
+ client=serialized.client,
81
+ model=serialized.model,
82
+ effort=serialized.effort,
83
+ context_fingerprint=fields["context_fingerprint"],
84
+ prompt_fingerprint=fields["prompt_fingerprint"],
85
+ input_file_fingerprints=dict(fields["input_file_fingerprints"]),
86
+ client_args_fingerprint=fields["client_args_fingerprint"],
87
+ grants=frozenset(fields["grants"]),
88
+ )
89
+ if serialized.kind == ExecutionKind.LOCAL_PASSTHROUGH.value:
90
+ return PassthroughCallIdentity(
91
+ client=serialized.client,
92
+ native_args_fingerprint=fields["native_args_fingerprint"],
93
+ )
94
+ if serialized.kind == ExecutionKind.API.value:
95
+ return ApiCallIdentity(
96
+ provider=serialized.client,
97
+ model=serialized.model,
98
+ messages_fingerprint=fields["messages_fingerprint"],
99
+ )
100
+ raise ValueError(f"cannot deserialize unknown identity kind: {serialized.kind!r}")
@@ -0,0 +1,69 @@
1
+ # SPDX-FileCopyrightText: 2026 Daniel Slobozian
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """InMemoryExecutionRepository: an ephemeral, append-only execution store."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from dataclasses import replace
8
+ from typing import Dict, List, Optional
9
+
10
+ from generic_ml_cache_core.application.domain.model.execution.execution_state import ExecutionState
11
+ from generic_ml_cache_core.application.domain.model.execution.ml_execution import MlExecution
12
+ from generic_ml_cache_core.application.port.out.clock_port import ClockPort
13
+ from generic_ml_cache_core.application.port.out.execution_repository_port import (
14
+ ExecutionRepositoryPort,
15
+ )
16
+
17
+
18
+ class InMemoryExecutionRepository(ExecutionRepositoryPort):
19
+ """An in-memory, append-only implementation of the execution repository.
20
+
21
+ Holds only structure: every saved execution is dehydrated (artifact bytes
22
+ dropped) before storage, so the repository never carries output content —
23
+ the bytes live in the blob store. Suitable as an ephemeral cache for a
24
+ library consumer and as a faithful test double that forces the use case down
25
+ the same hydrate-from-blob path the durable adapter will.
26
+
27
+ The clock is injected (it stamps supersession), so time is deterministic.
28
+ """
29
+
30
+ def __init__(self, clock: ClockPort) -> None:
31
+ self._clock = clock
32
+ self._by_key: Dict[str, List[MlExecution]] = {}
33
+
34
+ def find_current(self, execution_key: str) -> Optional[MlExecution]:
35
+ for execution in self._by_key.get(execution_key, []):
36
+ if self._is_servable(execution):
37
+ return replace(execution)
38
+ return None
39
+
40
+ def find_all(self, execution_key: str) -> List[MlExecution]:
41
+ return [replace(execution) for execution in self._by_key.get(execution_key, [])]
42
+
43
+ def save(self, execution: MlExecution) -> None:
44
+ execution_key = execution.call_identity.generate_key()
45
+ stored = self._dehydrate(execution)
46
+ history = self._by_key.setdefault(execution_key, [])
47
+ if self._is_servable(stored):
48
+ superseded_at = self._clock.now()
49
+ for prior in history:
50
+ if self._is_servable(prior):
51
+ prior.superseded_at = superseded_at
52
+ history.append(stored)
53
+
54
+ @staticmethod
55
+ def _is_servable(execution: MlExecution) -> bool:
56
+ """A servable execution is the current cached answer: a persisted success
57
+ that has not been superseded."""
58
+ return (
59
+ execution.execution_state is ExecutionState.SUCCESS
60
+ and execution.output_persisted
61
+ and execution.superseded_at is None
62
+ )
63
+
64
+ @staticmethod
65
+ def _dehydrate(execution: MlExecution) -> MlExecution:
66
+ """Return a copy whose artifacts carry no bytes — the repository stores
67
+ structure only; the bytes belong to the blob store."""
68
+ dehydrated_artifacts = [replace(artifact, content=None) for artifact in execution.artifacts]
69
+ return replace(execution, artifacts=dehydrated_artifacts)