loomcache 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.
- loom/__init__.py +49 -0
- loom/async_run.py +182 -0
- loom/cache.py +114 -0
- loom/cache_remote.py +143 -0
- loom/cli.py +92 -0
- loom/diff.py +58 -0
- loom/hashing.py +83 -0
- loom/langchain.py +133 -0
- loom/run.py +250 -0
- loom/trace.py +15 -0
- loom/tracing.py +132 -0
- loom/web.py +145 -0
- loomcache-1.0.0.dist-info/METADATA +302 -0
- loomcache-1.0.0.dist-info/RECORD +17 -0
- loomcache-1.0.0.dist-info/WHEEL +4 -0
- loomcache-1.0.0.dist-info/entry_points.txt +2 -0
- loomcache-1.0.0.dist-info/licenses/LICENSE +21 -0
loom/__init__.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Loom: a content-addressed, deterministic replay engine for agent AI workflows."""
|
|
2
|
+
|
|
3
|
+
from .cache import Cache, DiskCache, default_cache
|
|
4
|
+
from .diff import diff_runs, first_divergence, format_diff
|
|
5
|
+
from .run import Node, Run, current_run, step
|
|
6
|
+
from .tracing import TracedBox, get_node, attach_node, unwrap, unwrap_recursive
|
|
7
|
+
|
|
8
|
+
# Optional extensions – if dependencies are missing, these imports are skipped.
|
|
9
|
+
try:
|
|
10
|
+
from .cache_remote import S3Cache, RedisCache
|
|
11
|
+
except ImportError:
|
|
12
|
+
S3Cache = RedisCache = None
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
from .langchain import wrap_runnable, CachedRunnable
|
|
16
|
+
except ImportError:
|
|
17
|
+
wrap_runnable = CachedRunnable = None
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
from .async_run import AsyncRun, async_step, gather
|
|
21
|
+
except ImportError:
|
|
22
|
+
AsyncRun = async_step = gather = None
|
|
23
|
+
|
|
24
|
+
__version__ = "1.0.0"
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"step",
|
|
28
|
+
"Run",
|
|
29
|
+
"Node",
|
|
30
|
+
"current_run",
|
|
31
|
+
"Cache",
|
|
32
|
+
"DiskCache",
|
|
33
|
+
"default_cache",
|
|
34
|
+
"diff_runs",
|
|
35
|
+
"first_divergence",
|
|
36
|
+
"format_diff",
|
|
37
|
+
"get_node",
|
|
38
|
+
"attach_node",
|
|
39
|
+
"unwrap",
|
|
40
|
+
"unwrap_recursive",
|
|
41
|
+
"TracedBox",
|
|
42
|
+
"S3Cache",
|
|
43
|
+
"RedisCache",
|
|
44
|
+
"wrap_runnable",
|
|
45
|
+
"CachedRunnable",
|
|
46
|
+
"AsyncRun",
|
|
47
|
+
"async_step",
|
|
48
|
+
"gather",
|
|
49
|
+
]
|
loom/async_run.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Async execution support for Loom.
|
|
2
|
+
|
|
3
|
+
Use `AsyncRun` in an async context manager with async steps.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import contextvars
|
|
10
|
+
import functools
|
|
11
|
+
import json
|
|
12
|
+
import time
|
|
13
|
+
import uuid
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, Callable, Optional, Union
|
|
16
|
+
|
|
17
|
+
from loom import hashing, default_cache
|
|
18
|
+
from loom.run import Node, current_run
|
|
19
|
+
from loom.tracing import attach_node, get_node, unwrap_recursive
|
|
20
|
+
from loom.cache import Cache
|
|
21
|
+
|
|
22
|
+
_current_async_run: contextvars.ContextVar = contextvars.ContextVar(
|
|
23
|
+
"loom_current_async_run", default=None
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AsyncRun:
|
|
28
|
+
"""Async context manager for recording async step executions."""
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
name: str,
|
|
33
|
+
cache: Optional[Cache] = None,
|
|
34
|
+
root: Union[str, Path] = ".loom_runs",
|
|
35
|
+
):
|
|
36
|
+
self.name = name
|
|
37
|
+
self.run_id = f"{name}-{uuid.uuid4().hex[:8]}"
|
|
38
|
+
self.cache = cache or default_cache()
|
|
39
|
+
self.root = Path(root)
|
|
40
|
+
self.nodes: list = []
|
|
41
|
+
self.created_at = time.time()
|
|
42
|
+
self._token = None
|
|
43
|
+
|
|
44
|
+
async def __aenter__(self) -> AsyncRun:
|
|
45
|
+
self._token = _current_async_run.set(self)
|
|
46
|
+
return self
|
|
47
|
+
|
|
48
|
+
async def __aexit__(self, exc_type, exc, tb):
|
|
49
|
+
_current_async_run.reset(self._token)
|
|
50
|
+
|
|
51
|
+
def record(self, node: Node) -> None:
|
|
52
|
+
self.nodes.append(node)
|
|
53
|
+
|
|
54
|
+
def to_dict(self) -> dict:
|
|
55
|
+
return {
|
|
56
|
+
"run_id": self.run_id,
|
|
57
|
+
"name": self.name,
|
|
58
|
+
"created_at": self.created_at,
|
|
59
|
+
"nodes": [n.to_dict() for n in self.nodes],
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
def save(self, path: Optional[Union[str, Path]] = None) -> Path:
|
|
63
|
+
self.root.mkdir(parents=True, exist_ok=True)
|
|
64
|
+
out_path = Path(path) if path else self.root / f"{self.run_id}.json"
|
|
65
|
+
out_path.write_text(json.dumps(self.to_dict(), indent=2, default=str))
|
|
66
|
+
return out_path
|
|
67
|
+
|
|
68
|
+
def stats(self) -> dict:
|
|
69
|
+
hits = sum(1 for n in self.nodes if n.cache_hit)
|
|
70
|
+
total = len(self.nodes)
|
|
71
|
+
return {
|
|
72
|
+
"total_nodes": total,
|
|
73
|
+
"cache_hits": hits,
|
|
74
|
+
"cache_misses": total - hits,
|
|
75
|
+
"hit_rate": (hits / total) if total else 0.0,
|
|
76
|
+
"wall_time_s": round(sum(n.duration_s for n in self.nodes), 3),
|
|
77
|
+
"time_saved_s": round(sum(n.time_saved_s for n in self.nodes), 3),
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
@classmethod
|
|
81
|
+
def load(cls, path: Union[str, Path]) -> AsyncRun:
|
|
82
|
+
data = json.loads(Path(path).read_text())
|
|
83
|
+
run = cls.__new__(cls)
|
|
84
|
+
run.name = data["name"]
|
|
85
|
+
run.run_id = data["run_id"]
|
|
86
|
+
run.created_at = data["created_at"]
|
|
87
|
+
run.cache = default_cache()
|
|
88
|
+
run.root = Path(path).parent
|
|
89
|
+
run.nodes = [Node(**n) for n in data["nodes"]]
|
|
90
|
+
run._token = None
|
|
91
|
+
return run
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def async_step(func: Optional[Callable] = None, *, cache: Optional[Cache] = None):
|
|
95
|
+
"""Decorator that works for async functions.
|
|
96
|
+
|
|
97
|
+
If the function is async, it returns a coroutine that will be awaited
|
|
98
|
+
by the caller. For sync functions, use `@loom.step` instead.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
def decorator(f: Callable) -> Callable:
|
|
102
|
+
source_hash = hashing.hash_source(f)
|
|
103
|
+
is_coroutine = asyncio.iscoroutinefunction(f)
|
|
104
|
+
|
|
105
|
+
@functools.wraps(f)
|
|
106
|
+
async def async_wrapper(*args, **kwargs):
|
|
107
|
+
run = _current_async_run.get()
|
|
108
|
+
if run is None:
|
|
109
|
+
# Fallback to calling the function directly (could be sync or async)
|
|
110
|
+
if is_coroutine:
|
|
111
|
+
return await f(*args, **kwargs)
|
|
112
|
+
else:
|
|
113
|
+
return f(*args, **kwargs)
|
|
114
|
+
|
|
115
|
+
active_cache = cache or (run.cache if run is not None else default_cache())
|
|
116
|
+
node_hash = hashing.hash_node(f.__qualname__, source_hash, args, kwargs)
|
|
117
|
+
|
|
118
|
+
# Determine parents from traced arguments
|
|
119
|
+
parents = [
|
|
120
|
+
n.node_hash
|
|
121
|
+
for n in (get_node(a) for a in list(args) + list(kwargs.values()))
|
|
122
|
+
if n is not None
|
|
123
|
+
]
|
|
124
|
+
|
|
125
|
+
entry = active_cache.get(node_hash)
|
|
126
|
+
if entry is not None:
|
|
127
|
+
output = entry.output # already clean
|
|
128
|
+
cache_hit = True
|
|
129
|
+
duration = 0.0
|
|
130
|
+
time_saved = float(entry.metadata.get("duration_s", 0.0))
|
|
131
|
+
clean_output = output
|
|
132
|
+
else:
|
|
133
|
+
# Unwrap arguments
|
|
134
|
+
from loom.run import _unwrap
|
|
135
|
+
call_args = tuple(_unwrap(a) for a in args)
|
|
136
|
+
call_kwargs = {k: _unwrap(v) for k, v in kwargs.items()}
|
|
137
|
+
t0 = time.time()
|
|
138
|
+
if is_coroutine:
|
|
139
|
+
raw_output = await f(*call_args, **call_kwargs)
|
|
140
|
+
else:
|
|
141
|
+
raw_output = f(*call_args, **call_kwargs)
|
|
142
|
+
duration = time.time() - t0
|
|
143
|
+
|
|
144
|
+
clean_output = unwrap_recursive(raw_output)
|
|
145
|
+
|
|
146
|
+
active_cache.put(
|
|
147
|
+
node_hash,
|
|
148
|
+
clean_output,
|
|
149
|
+
metadata={
|
|
150
|
+
"step_name": f.__qualname__,
|
|
151
|
+
"timestamp": time.time(),
|
|
152
|
+
"duration_s": duration,
|
|
153
|
+
},
|
|
154
|
+
)
|
|
155
|
+
cache_hit = False
|
|
156
|
+
time_saved = 0.0
|
|
157
|
+
|
|
158
|
+
node = Node(
|
|
159
|
+
node_hash=node_hash,
|
|
160
|
+
step_name=f.__qualname__,
|
|
161
|
+
parents=parents,
|
|
162
|
+
cache_hit=cache_hit,
|
|
163
|
+
duration_s=duration,
|
|
164
|
+
time_saved_s=time_saved,
|
|
165
|
+
timestamp=time.time(),
|
|
166
|
+
args_repr=repr(args),
|
|
167
|
+
kwargs_repr=repr(kwargs),
|
|
168
|
+
output_repr=repr(clean_output),
|
|
169
|
+
)
|
|
170
|
+
run.record(node)
|
|
171
|
+
return attach_node(clean_output, node)
|
|
172
|
+
|
|
173
|
+
return async_wrapper
|
|
174
|
+
|
|
175
|
+
if func is not None:
|
|
176
|
+
return decorator(func)
|
|
177
|
+
return decorator
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
async def gather(*coros, return_exceptions=False):
|
|
181
|
+
"""Run multiple async steps concurrently, with Loom tracking."""
|
|
182
|
+
return await asyncio.gather(*coros, return_exceptions=return_exceptions)
|
loom/cache.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Content-addressed cache backends.
|
|
2
|
+
|
|
3
|
+
Loom stores every step's output under a key derived purely from its
|
|
4
|
+
content hash — the same idea as Git's object store or Bazel's action
|
|
5
|
+
cache. Two runs (even in different processes, on different days, on
|
|
6
|
+
different machines sharing this cache) that produce the same hash will
|
|
7
|
+
transparently share the same cached output.
|
|
8
|
+
|
|
9
|
+
`Cache` is a tiny abstract interface so alternate backends (Redis, S3,
|
|
10
|
+
a shared network drive for a team) can be dropped in later without
|
|
11
|
+
touching the rest of Loom.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import pickle
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any, Optional, Union
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class CacheEntry:
|
|
24
|
+
output: Any
|
|
25
|
+
metadata: dict
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Cache:
|
|
29
|
+
"""Abstract cache interface. Subclass to plug in Redis/S3/etc."""
|
|
30
|
+
|
|
31
|
+
def get(self, key: str) -> Optional[CacheEntry]:
|
|
32
|
+
raise NotImplementedError
|
|
33
|
+
|
|
34
|
+
def put(self, key: str, output: Any, metadata: dict) -> None:
|
|
35
|
+
raise NotImplementedError
|
|
36
|
+
|
|
37
|
+
def stats(self) -> dict:
|
|
38
|
+
raise NotImplementedError
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class DiskCache(Cache):
|
|
42
|
+
"""Default local cache: `<root>/objects/<hash[:2]>/<hash>.pkl`.
|
|
43
|
+
|
|
44
|
+
Sharding by the first two hex characters keeps any one directory
|
|
45
|
+
from accumulating too many files, the same trick Git uses for its
|
|
46
|
+
object store.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(self, root: Union[str, Path] = ".loom_cache"):
|
|
50
|
+
self.root = Path(root)
|
|
51
|
+
self.objects_dir = self.root / "objects"
|
|
52
|
+
self.objects_dir.mkdir(parents=True, exist_ok=True)
|
|
53
|
+
self._hits = 0
|
|
54
|
+
self._misses = 0
|
|
55
|
+
|
|
56
|
+
def _paths(self, key: str):
|
|
57
|
+
shard = self.objects_dir / key[:2]
|
|
58
|
+
shard.mkdir(parents=True, exist_ok=True)
|
|
59
|
+
return shard / f"{key}.pkl", shard / f"{key}.json"
|
|
60
|
+
|
|
61
|
+
def get(self, key: str) -> Optional[CacheEntry]:
|
|
62
|
+
data_path, meta_path = self._paths(key)
|
|
63
|
+
if not data_path.exists():
|
|
64
|
+
self._misses += 1
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
# Remove empty or corrupted files
|
|
68
|
+
if data_path.stat().st_size == 0:
|
|
69
|
+
data_path.unlink()
|
|
70
|
+
if meta_path.exists():
|
|
71
|
+
meta_path.unlink()
|
|
72
|
+
self._misses += 1
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
with open(data_path, "rb") as f:
|
|
77
|
+
output = pickle.load(f)
|
|
78
|
+
except (EOFError, pickle.UnpicklingError, Exception):
|
|
79
|
+
# Corrupt file – delete it and treat as a miss
|
|
80
|
+
data_path.unlink()
|
|
81
|
+
if meta_path.exists():
|
|
82
|
+
meta_path.unlink()
|
|
83
|
+
self._misses += 1
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
self._hits += 1
|
|
87
|
+
metadata = {}
|
|
88
|
+
if meta_path.exists():
|
|
89
|
+
try:
|
|
90
|
+
metadata = json.loads(meta_path.read_text())
|
|
91
|
+
except Exception:
|
|
92
|
+
metadata = {}
|
|
93
|
+
return CacheEntry(output=output, metadata=metadata)
|
|
94
|
+
|
|
95
|
+
def put(self, key: str, output: Any, metadata: dict) -> None:
|
|
96
|
+
data_path, meta_path = self._paths(key)
|
|
97
|
+
with open(data_path, "wb") as f:
|
|
98
|
+
pickle.dump(output, f)
|
|
99
|
+
meta_path.write_text(json.dumps(metadata, indent=2, default=str))
|
|
100
|
+
|
|
101
|
+
def stats(self) -> dict:
|
|
102
|
+
total = self._hits + self._misses
|
|
103
|
+
hit_rate = self._hits / total if total else 0.0
|
|
104
|
+
return {"hits": self._hits, "misses": self._misses, "hit_rate": hit_rate}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
_default_cache: Optional[DiskCache] = None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def default_cache() -> DiskCache:
|
|
111
|
+
global _default_cache
|
|
112
|
+
if _default_cache is None:
|
|
113
|
+
_default_cache = DiskCache()
|
|
114
|
+
return _default_cache
|
loom/cache_remote.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Remote cache backends: S3 and Redis.
|
|
2
|
+
|
|
3
|
+
To use, install the corresponding extra:
|
|
4
|
+
|
|
5
|
+
pip install loomtrace[s3] # for S3
|
|
6
|
+
pip install loomtrace[redis] # for Redis
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import pickle
|
|
13
|
+
from typing import Any, Optional
|
|
14
|
+
|
|
15
|
+
from .cache import Cache, CacheEntry
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class S3Cache(Cache):
|
|
19
|
+
"""Amazon S3 cache backend.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
bucket: S3 bucket name.
|
|
23
|
+
prefix: Key prefix (e.g., "loom_cache/").
|
|
24
|
+
region_name: AWS region, optional.
|
|
25
|
+
aws_access_key_id, aws_secret_access_key: Optional credentials.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
bucket: str,
|
|
31
|
+
prefix: str = "loom_cache/",
|
|
32
|
+
region_name: Optional[str] = None,
|
|
33
|
+
aws_access_key_id: Optional[str] = None,
|
|
34
|
+
aws_secret_access_key: Optional[str] = None,
|
|
35
|
+
):
|
|
36
|
+
try:
|
|
37
|
+
import boto3
|
|
38
|
+
except ImportError as exc:
|
|
39
|
+
raise ImportError("Install boto3: pip install loomtrace[s3]") from exc
|
|
40
|
+
|
|
41
|
+
self.bucket = bucket
|
|
42
|
+
self.prefix = prefix.rstrip("/") + "/"
|
|
43
|
+
self.s3 = boto3.client(
|
|
44
|
+
"s3",
|
|
45
|
+
region_name=region_name,
|
|
46
|
+
aws_access_key_id=aws_access_key_id,
|
|
47
|
+
aws_secret_access_key=aws_secret_access_key,
|
|
48
|
+
)
|
|
49
|
+
self._hits = 0
|
|
50
|
+
self._misses = 0
|
|
51
|
+
|
|
52
|
+
def _key(self, key: str) -> str:
|
|
53
|
+
return f"{self.prefix}{key[:2]}/{key}.pkl"
|
|
54
|
+
|
|
55
|
+
def _meta_key(self, key: str) -> str:
|
|
56
|
+
return f"{self.prefix}{key[:2]}/{key}.json"
|
|
57
|
+
|
|
58
|
+
def get(self, key: str) -> Optional[CacheEntry]:
|
|
59
|
+
try:
|
|
60
|
+
obj = self.s3.get_object(Bucket=self.bucket, Key=self._key(key))
|
|
61
|
+
output = pickle.loads(obj["Body"].read())
|
|
62
|
+
except self.s3.exceptions.NoSuchKey:
|
|
63
|
+
self._misses += 1
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
self._hits += 1
|
|
67
|
+
metadata = {}
|
|
68
|
+
try:
|
|
69
|
+
meta_obj = self.s3.get_object(Bucket=self.bucket, Key=self._meta_key(key))
|
|
70
|
+
metadata = json.loads(meta_obj["Body"].read().decode())
|
|
71
|
+
except self.s3.exceptions.NoSuchKey:
|
|
72
|
+
pass
|
|
73
|
+
|
|
74
|
+
return CacheEntry(output=output, metadata=metadata)
|
|
75
|
+
|
|
76
|
+
def put(self, key: str, output: Any, metadata: dict) -> None:
|
|
77
|
+
self.s3.put_object(
|
|
78
|
+
Bucket=self.bucket,
|
|
79
|
+
Key=self._key(key),
|
|
80
|
+
Body=pickle.dumps(output),
|
|
81
|
+
)
|
|
82
|
+
self.s3.put_object(
|
|
83
|
+
Bucket=self.bucket,
|
|
84
|
+
Key=self._meta_key(key),
|
|
85
|
+
Body=json.dumps(metadata).encode(),
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
def stats(self) -> dict:
|
|
89
|
+
total = self._hits + self._misses
|
|
90
|
+
return {
|
|
91
|
+
"hits": self._hits,
|
|
92
|
+
"misses": self._misses,
|
|
93
|
+
"hit_rate": self._hits / total if total else 0.0,
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class RedisCache(Cache):
|
|
98
|
+
"""Redis cache backend.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
url: Redis URL (e.g., redis://localhost:6379/0).
|
|
102
|
+
key_prefix: Prefix for all keys.
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
def __init__(self, url: str = "redis://localhost:6379/0", key_prefix: str = "loom:"):
|
|
106
|
+
try:
|
|
107
|
+
import redis
|
|
108
|
+
except ImportError as exc:
|
|
109
|
+
raise ImportError("Install redis-py: pip install loomtrace[redis]") from exc
|
|
110
|
+
|
|
111
|
+
self.redis = redis.from_url(url)
|
|
112
|
+
self.key_prefix = key_prefix
|
|
113
|
+
self._hits = 0
|
|
114
|
+
self._misses = 0
|
|
115
|
+
|
|
116
|
+
def _data_key(self, key: str) -> str:
|
|
117
|
+
return f"{self.key_prefix}{key}:data"
|
|
118
|
+
|
|
119
|
+
def _meta_key(self, key: str) -> str:
|
|
120
|
+
return f"{self.key_prefix}{key}:meta"
|
|
121
|
+
|
|
122
|
+
def get(self, key: str) -> Optional[CacheEntry]:
|
|
123
|
+
data = self.redis.get(self._data_key(key))
|
|
124
|
+
if data is None:
|
|
125
|
+
self._misses += 1
|
|
126
|
+
return None
|
|
127
|
+
self._hits += 1
|
|
128
|
+
output = pickle.loads(data)
|
|
129
|
+
meta = self.redis.get(self._meta_key(key))
|
|
130
|
+
metadata = json.loads(meta) if meta else {}
|
|
131
|
+
return CacheEntry(output=output, metadata=metadata)
|
|
132
|
+
|
|
133
|
+
def put(self, key: str, output: Any, metadata: dict) -> None:
|
|
134
|
+
self.redis.set(self._data_key(key), pickle.dumps(output))
|
|
135
|
+
self.redis.set(self._meta_key(key), json.dumps(metadata))
|
|
136
|
+
|
|
137
|
+
def stats(self) -> dict:
|
|
138
|
+
total = self._hits + self._misses
|
|
139
|
+
return {
|
|
140
|
+
"hits": self._hits,
|
|
141
|
+
"misses": self._misses,
|
|
142
|
+
"hit_rate": self._hits / total if total else 0.0,
|
|
143
|
+
}
|
loom/cli.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Command-line interface for Loom.
|
|
2
|
+
|
|
3
|
+
loom show <run.json>
|
|
4
|
+
loom diff <run_a.json> <run_b.json>
|
|
5
|
+
loom stats <run.json>
|
|
6
|
+
loom web [--runs-dir DIR] [--host HOST] [--port PORT]
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
|
|
12
|
+
from .diff import diff_runs, format_diff
|
|
13
|
+
from .run import Run
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def cmd_show(args):
|
|
17
|
+
run = Run.load(args.run_path)
|
|
18
|
+
print(f"Run: {run.name} ({run.run_id})")
|
|
19
|
+
print(f"Nodes: {len(run.nodes)}\n")
|
|
20
|
+
for i, node in enumerate(run.nodes):
|
|
21
|
+
tag = "HIT " if node.cache_hit else "MISS"
|
|
22
|
+
print(f"[{i}] {tag} {node.step_name} ({node.node_hash[:10]}...)")
|
|
23
|
+
print(f" -> {node.output_repr}")
|
|
24
|
+
stats = run.stats()
|
|
25
|
+
print(
|
|
26
|
+
f"\nCache hit rate: {stats['hit_rate']:.0%} "
|
|
27
|
+
f"({stats['cache_hits']}/{stats['total_nodes']})"
|
|
28
|
+
)
|
|
29
|
+
print(f"Wall time: {stats['wall_time_s']}s Time saved by cache: {stats['time_saved_s']}s")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def cmd_diff(args):
|
|
33
|
+
run_a = Run.load(args.run_a)
|
|
34
|
+
run_b = Run.load(args.run_b)
|
|
35
|
+
diffs = diff_runs(run_a, run_b)
|
|
36
|
+
print(format_diff(diffs))
|
|
37
|
+
changed = [d for d in diffs if d.status != "same"]
|
|
38
|
+
if changed:
|
|
39
|
+
print(
|
|
40
|
+
f"\n{len(changed)} node(s) differ. First divergence: "
|
|
41
|
+
f"[{changed[0].index}] {changed[0].step_name}"
|
|
42
|
+
)
|
|
43
|
+
else:
|
|
44
|
+
print("\nRuns are identical.")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def cmd_stats(args):
|
|
48
|
+
run = Run.load(args.run_path)
|
|
49
|
+
for k, v in run.stats().items():
|
|
50
|
+
print(f"{k}: {v}")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def cmd_web(args):
|
|
54
|
+
try:
|
|
55
|
+
from loom.web import serve
|
|
56
|
+
except ImportError as exc:
|
|
57
|
+
raise SystemExit("Install loomtrace[web] to use the web UI.") from exc
|
|
58
|
+
serve(runs_dir=args.runs_dir, host=args.host, port=args.port)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def main(argv=None):
|
|
62
|
+
parser = argparse.ArgumentParser(
|
|
63
|
+
prog="loom",
|
|
64
|
+
description="Loom: content-addressed execution engine for agent workflows.",
|
|
65
|
+
)
|
|
66
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
67
|
+
|
|
68
|
+
p_show = sub.add_parser("show", help="Show all nodes in a saved run")
|
|
69
|
+
p_show.add_argument("run_path")
|
|
70
|
+
p_show.set_defaults(func=cmd_show)
|
|
71
|
+
|
|
72
|
+
p_diff = sub.add_parser("diff", help="Diff two saved runs node-by-node")
|
|
73
|
+
p_diff.add_argument("run_a")
|
|
74
|
+
p_diff.add_argument("run_b")
|
|
75
|
+
p_diff.set_defaults(func=cmd_diff)
|
|
76
|
+
|
|
77
|
+
p_stats = sub.add_parser("stats", help="Show cache hit-rate stats for a run")
|
|
78
|
+
p_stats.add_argument("run_path")
|
|
79
|
+
p_stats.set_defaults(func=cmd_stats)
|
|
80
|
+
|
|
81
|
+
p_web = sub.add_parser("web", help="Launch web UI")
|
|
82
|
+
p_web.add_argument("--runs-dir", default=".loom_runs", help="Directory containing run JSONs")
|
|
83
|
+
p_web.add_argument("--host", default="127.0.0.1", help="Host to bind")
|
|
84
|
+
p_web.add_argument("--port", type=int, default=5000, help="Port to bind")
|
|
85
|
+
p_web.set_defaults(func=cmd_web)
|
|
86
|
+
|
|
87
|
+
args = parser.parse_args(argv)
|
|
88
|
+
args.func(args)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
if __name__ == "__main__":
|
|
92
|
+
main()
|
loom/diff.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Node-level diffing between two runs.
|
|
2
|
+
|
|
3
|
+
See exactly *where* two pipeline executions first diverge — not just
|
|
4
|
+
whether their final outputs differ.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import List, Optional
|
|
10
|
+
|
|
11
|
+
from .run import Run, Node
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class NodeDiff:
|
|
16
|
+
index: int
|
|
17
|
+
step_name: str
|
|
18
|
+
status: str # "same" | "changed" | "only_in_a" | "only_in_b"
|
|
19
|
+
node_a: Optional[Node]
|
|
20
|
+
node_b: Optional[Node]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def diff_runs(run_a: Run, run_b: Run) -> List[NodeDiff]:
|
|
24
|
+
"""Compare two runs node-by-node, in call order."""
|
|
25
|
+
diffs: List[NodeDiff] = []
|
|
26
|
+
max_len = max(len(run_a.nodes), len(run_b.nodes))
|
|
27
|
+
for i in range(max_len):
|
|
28
|
+
na = run_a.nodes[i] if i < len(run_a.nodes) else None
|
|
29
|
+
nb = run_b.nodes[i] if i < len(run_b.nodes) else None
|
|
30
|
+
if na is None:
|
|
31
|
+
diffs.append(NodeDiff(i, nb.step_name, "only_in_b", na, nb))
|
|
32
|
+
elif nb is None:
|
|
33
|
+
diffs.append(NodeDiff(i, na.step_name, "only_in_a", na, nb))
|
|
34
|
+
elif na.node_hash == nb.node_hash:
|
|
35
|
+
diffs.append(NodeDiff(i, na.step_name, "same", na, nb))
|
|
36
|
+
else:
|
|
37
|
+
diffs.append(NodeDiff(i, na.step_name, "changed", na, nb))
|
|
38
|
+
return diffs
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def first_divergence(run_a: Run, run_b: Run) -> Optional[NodeDiff]:
|
|
42
|
+
"""The earliest node (in call order) where the two runs differ, or
|
|
43
|
+
None if they are identical."""
|
|
44
|
+
for d in diff_runs(run_a, run_b):
|
|
45
|
+
if d.status != "same":
|
|
46
|
+
return d
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def format_diff(diffs: List[NodeDiff]) -> str:
|
|
51
|
+
symbols = {"same": " ", "changed": "~", "only_in_a": "-", "only_in_b": "+"}
|
|
52
|
+
lines = []
|
|
53
|
+
for d in diffs:
|
|
54
|
+
lines.append(f"{symbols[d.status]} [{d.index}] {d.step_name} ({d.status})")
|
|
55
|
+
if d.status == "changed":
|
|
56
|
+
lines.append(f" a: {d.node_a.output_repr}")
|
|
57
|
+
lines.append(f" b: {d.node_b.output_repr}")
|
|
58
|
+
return "\n".join(lines)
|