logogram 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- logogram/__init__.py +6 -0
- logogram/__main__.py +5 -0
- logogram/analysis.py +419 -0
- logogram/atp.py +120 -0
- logogram/backends/__init__.py +5 -0
- logogram/backends/base.py +202 -0
- logogram/backends/hub.py +375 -0
- logogram/backends/saes.py +277 -0
- logogram/backends/transformer_lens.py +872 -0
- logogram/cli.py +496 -0
- logogram/compare.py +177 -0
- logogram/datasets.py +159 -0
- logogram/direct.py +193 -0
- logogram/engine.py +550 -0
- logogram/examples/ioi-gpt2/.gitignore +3 -0
- logogram/examples/ioi-gpt2/datasets/ioi.jsonl +32 -0
- logogram/examples/ioi-gpt2/experiments/ioi-head-patching/spec.json +42 -0
- logogram/examples/ioi-gpt2/project.json +6 -0
- logogram/exports.py +33 -0
- logogram/features.py +368 -0
- logogram/fileio.py +63 -0
- logogram/ioi.py +220 -0
- logogram/paths.py +204 -0
- logogram/project.py +444 -0
- logogram/prompts.py +204 -0
- logogram/research.py +84 -0
- logogram/results.py +240 -0
- logogram/runner.py +396 -0
- logogram/runs.py +98 -0
- logogram/sae.py +161 -0
- logogram/schema.py +302 -0
- logogram/server/__init__.py +1 -0
- logogram/server/app.py +1083 -0
- logogram/server/models.py +426 -0
- logogram/server/security.py +212 -0
- logogram/server/state.py +585 -0
- logogram/sites.py +249 -0
- logogram/spec.py +518 -0
- logogram/stats.py +171 -0
- logogram/steering.py +258 -0
- logogram/system.py +379 -0
- logogram/updates.py +194 -0
- logogram/verify.py +39 -0
- logogram/web_dist/assets/index-BvCU-2uy.js +54 -0
- logogram/web_dist/assets/index-DTr8_ucV.css +1 -0
- logogram/web_dist/assets/instrument-sans-latin-ext-standard-normal-C5E2Gvlv.woff2 +0 -0
- logogram/web_dist/assets/instrument-sans-latin-standard-normal-BVScPF0l.woff2 +0 -0
- logogram/web_dist/favicon.svg +1 -0
- logogram/web_dist/index.html +15 -0
- logogram-0.1.0.dist-info/METADATA +550 -0
- logogram-0.1.0.dist-info/RECORD +54 -0
- logogram-0.1.0.dist-info/WHEEL +4 -0
- logogram-0.1.0.dist-info/entry_points.txt +2 -0
- logogram-0.1.0.dist-info/licenses/LICENSE +21 -0
logogram/paths.py
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""Path patching: a component's effect through chosen receivers only.
|
|
2
|
+
|
|
3
|
+
For each sender (a head, attention output or MLP output of the scope) and receiver prompt:
|
|
4
|
+
|
|
5
|
+
1. run the receiver prompt with the sender's activation from the source prompt, and every other
|
|
6
|
+
attention head held at its own value in the receiver run (and the MLPs too, if frozen), so the
|
|
7
|
+
change reaches later layers only through the residual stream (and the MLPs); record what the
|
|
8
|
+
receivers read: a later head's query, key or value, or the final residual stream;
|
|
9
|
+
2. run the receiver prompt again with only those receiver inputs replaced by the recorded ones,
|
|
10
|
+
and read the metric.
|
|
11
|
+
|
|
12
|
+
The normalized effect is computed as for patching. A sender with no receiver after it has no path,
|
|
13
|
+
so a sweep keeps only the layers before the last receiver.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import threading
|
|
19
|
+
from collections.abc import Callable
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
import numpy as np
|
|
23
|
+
import torch
|
|
24
|
+
|
|
25
|
+
from logogram.backends.base import Cancelled, ModelBackend, ModelInfo
|
|
26
|
+
from logogram.engine import (
|
|
27
|
+
EngineResult,
|
|
28
|
+
LayerFn,
|
|
29
|
+
ProgressFn,
|
|
30
|
+
_answer_tensors,
|
|
31
|
+
_build_patch,
|
|
32
|
+
_chunks,
|
|
33
|
+
_LayerSources,
|
|
34
|
+
_metric,
|
|
35
|
+
_Row,
|
|
36
|
+
check_gap,
|
|
37
|
+
compute_baselines,
|
|
38
|
+
)
|
|
39
|
+
from logogram.prompts import PreparedPrompt, group_by_length
|
|
40
|
+
from logogram.sites import ResolvedSite, ScopeError, expand_scope
|
|
41
|
+
from logogram.spec import AllPositions, HeadReceiver, PathPatching, Spec
|
|
42
|
+
|
|
43
|
+
SENDER_KINDS = ("head", "attn_out", "mlp_out")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def check_receivers(exp: PathPatching, info: ModelInfo) -> None:
|
|
47
|
+
for receiver in exp.receivers:
|
|
48
|
+
if not isinstance(receiver, HeadReceiver):
|
|
49
|
+
continue
|
|
50
|
+
if receiver.layer >= info.n_layers or receiver.head >= info.n_heads:
|
|
51
|
+
raise ScopeError(
|
|
52
|
+
f"L{receiver.layer} H{receiver.head} doesn't exist; this model has "
|
|
53
|
+
f"{info.n_layers} layers of {info.n_heads} heads."
|
|
54
|
+
)
|
|
55
|
+
kv = int(info.extra.get("n_key_value_heads") or info.n_heads)
|
|
56
|
+
if receiver.input in ("k", "v") and kv != info.n_heads:
|
|
57
|
+
raise ScopeError(
|
|
58
|
+
f"This model shares each key and value among {info.n_heads // kv} heads, so a "
|
|
59
|
+
"single head's key or value can't receive a path on its own. Use query receivers."
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def path_sites(
|
|
64
|
+
spec: Spec, info: ModelInfo, prompts: list[PreparedPrompt]
|
|
65
|
+
) -> tuple[list[ResolvedSite], dict[str, Any]]:
|
|
66
|
+
"""The scope's senders that come before a receiver, re-indexed, and their layout."""
|
|
67
|
+
exp = spec.experiment
|
|
68
|
+
assert isinstance(exp, PathPatching)
|
|
69
|
+
sites, layout = expand_scope(spec, info, prompts)
|
|
70
|
+
for rs in sites:
|
|
71
|
+
if rs.kind not in SENDER_KINDS:
|
|
72
|
+
raise ScopeError(
|
|
73
|
+
f"{rs.label} is a residual stream state. Path patching sends from heads, "
|
|
74
|
+
"attention outputs or MLP outputs; choose those."
|
|
75
|
+
)
|
|
76
|
+
heads = [r.layer for r in exp.receivers if isinstance(r, HeadReceiver)]
|
|
77
|
+
limit = info.n_layers if len(heads) < len(exp.receivers) else max(heads)
|
|
78
|
+
kept = [rs for rs in sites if rs.layer < limit]
|
|
79
|
+
if not kept:
|
|
80
|
+
raise ScopeError(
|
|
81
|
+
"No sender comes before a receiver, so there is no path to patch. Choose receivers in "
|
|
82
|
+
"later layers, or add the logits."
|
|
83
|
+
)
|
|
84
|
+
if layout["kind"] in ("heads", "layer_components", "layer_position"):
|
|
85
|
+
layout = {**layout, "rows": layout["rows"][:limit]}
|
|
86
|
+
elif layout["kind"] == "sites":
|
|
87
|
+
layout = {**layout, "rows": [layout["rows"][rs.row] for rs in kept]}
|
|
88
|
+
out = []
|
|
89
|
+
for i, rs in enumerate(kept):
|
|
90
|
+
row = i if layout["kind"] == "sites" else rs.row
|
|
91
|
+
out.append(ResolvedSite(i, rs.site, row, rs.col, rs.label))
|
|
92
|
+
return out, layout
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def run_path_patching(
|
|
96
|
+
spec: Spec,
|
|
97
|
+
backend: ModelBackend,
|
|
98
|
+
prompts: list[PreparedPrompt],
|
|
99
|
+
*,
|
|
100
|
+
on_progress: ProgressFn | None = None,
|
|
101
|
+
on_layer: LayerFn | None = None,
|
|
102
|
+
cancel: threading.Event | None = None,
|
|
103
|
+
on_start: Callable[[list[ResolvedSite], dict[str, Any]], None] | None = None,
|
|
104
|
+
) -> EngineResult:
|
|
105
|
+
exp = spec.experiment
|
|
106
|
+
assert isinstance(exp, PathPatching)
|
|
107
|
+
info = backend.info
|
|
108
|
+
batch_size = spec.execution.batch_size
|
|
109
|
+
check_receivers(exp, info)
|
|
110
|
+
sites, layout = path_sites(spec, info, prompts)
|
|
111
|
+
if on_start is not None:
|
|
112
|
+
on_start(sites, layout)
|
|
113
|
+
groups = group_by_length(prompts)
|
|
114
|
+
baselines = compute_baselines(backend, prompts, groups, batch_size, cancel)
|
|
115
|
+
receiver, source = (
|
|
116
|
+
("corrupt", "clean") if exp.direction == "clean_to_corrupt" else ("clean", "corrupt")
|
|
117
|
+
)
|
|
118
|
+
reference = source
|
|
119
|
+
warnings = check_gap(spec, baselines, prompts, receiver, reference)
|
|
120
|
+
receivers = [
|
|
121
|
+
("head", r.layer, r.head, r.input)
|
|
122
|
+
if isinstance(r, HeadReceiver)
|
|
123
|
+
else ("logits", -1, -1, "")
|
|
124
|
+
for r in exp.receivers
|
|
125
|
+
]
|
|
126
|
+
group_of = {i: gi for gi, g in enumerate(groups) for i in g.members}
|
|
127
|
+
local_of = {i: li for g in groups for li, i in enumerate(g.members)}
|
|
128
|
+
n = len(prompts)
|
|
129
|
+
n_layers = info.n_layers
|
|
130
|
+
model_dtype = {"float32": torch.float32, "float16": torch.float16, "bfloat16": torch.bfloat16}
|
|
131
|
+
dtype = model_dtype[info.dtype]
|
|
132
|
+
patched_ld = np.zeros((len(sites), n))
|
|
133
|
+
patched_prob = np.zeros((len(sites), n))
|
|
134
|
+
result = EngineResult(
|
|
135
|
+
sites=sites,
|
|
136
|
+
layout=layout,
|
|
137
|
+
prompts=prompts,
|
|
138
|
+
baselines=baselines,
|
|
139
|
+
receiver=receiver,
|
|
140
|
+
reference=reference,
|
|
141
|
+
patched_ld=patched_ld,
|
|
142
|
+
patched_prob=patched_prob,
|
|
143
|
+
warnings=warnings,
|
|
144
|
+
)
|
|
145
|
+
held = [("head", layer) for layer in range(n_layers)]
|
|
146
|
+
if exp.freeze_mlps:
|
|
147
|
+
held += [("mlp_out", layer) for layer in range(n_layers)]
|
|
148
|
+
total = len(sites) * n
|
|
149
|
+
done = 0
|
|
150
|
+
for layer in sorted({rs.layer for rs in sites}):
|
|
151
|
+
layer_sites = [rs for rs in sites if rs.layer == layer]
|
|
152
|
+
kinds = list(dict.fromkeys(rs.kind for rs in layer_sites))
|
|
153
|
+
sources = _LayerSources(backend, prompts, groups, layer, kinds, source, batch_size, cancel)
|
|
154
|
+
for gi, group in enumerate(groups):
|
|
155
|
+
tokens = group.clean if receiver == "clean" else group.corrupt
|
|
156
|
+
buckets: dict[tuple[str, bool], list[_Row]] = {}
|
|
157
|
+
for rs in layer_sites:
|
|
158
|
+
all_pos = isinstance(rs.site.position, AllPositions)
|
|
159
|
+
for p in group.members:
|
|
160
|
+
buckets.setdefault((rs.kind, all_pos), []).append(_Row(rs, p, local_of[p]))
|
|
161
|
+
for (kind, all_pos), rows in buckets.items():
|
|
162
|
+
for sl in _chunks(len(rows), batch_size):
|
|
163
|
+
if cancel is not None and cancel.is_set():
|
|
164
|
+
raise Cancelled()
|
|
165
|
+
chunk = rows[sl]
|
|
166
|
+
sender = _build_patch(
|
|
167
|
+
chunk,
|
|
168
|
+
kind,
|
|
169
|
+
layer,
|
|
170
|
+
all_pos,
|
|
171
|
+
"patch",
|
|
172
|
+
sources,
|
|
173
|
+
gi,
|
|
174
|
+
group.length,
|
|
175
|
+
group_of,
|
|
176
|
+
local_of,
|
|
177
|
+
prompts,
|
|
178
|
+
info.d_model,
|
|
179
|
+
info.d_head,
|
|
180
|
+
dtype,
|
|
181
|
+
backend.device,
|
|
182
|
+
)
|
|
183
|
+
local = torch.tensor([r.local for r in chunk], dtype=torch.long)
|
|
184
|
+
# The receiver run's own head (and MLP) outputs, which the first pass holds.
|
|
185
|
+
own = backend.capture(tokens[local], held)
|
|
186
|
+
frozen_heads = {i: own[("head", i)] for i in range(n_layers)}
|
|
187
|
+
frozen_mlps = (
|
|
188
|
+
{i: own[("mlp_out", i)] for i in range(n_layers)}
|
|
189
|
+
if exp.freeze_mlps
|
|
190
|
+
else None
|
|
191
|
+
)
|
|
192
|
+
logits = backend.path_patch(
|
|
193
|
+
tokens[local], sender, frozen_heads, frozen_mlps, receivers
|
|
194
|
+
)
|
|
195
|
+
ld, prob = _metric(logits, *_answer_tensors(prompts, [r.prompt for r in chunk]))
|
|
196
|
+
for r, value_ld, value_prob in zip(chunk, ld, prob, strict=True):
|
|
197
|
+
patched_ld[r.site.index, r.prompt] = value_ld
|
|
198
|
+
patched_prob[r.site.index, r.prompt] = value_prob
|
|
199
|
+
done += len(chunk)
|
|
200
|
+
if on_progress is not None:
|
|
201
|
+
on_progress(done, total, layer)
|
|
202
|
+
if on_layer is not None:
|
|
203
|
+
on_layer(layer, [rs.index for rs in layer_sites], result)
|
|
204
|
+
return result
|
logogram/project.py
ADDED
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
"""Projects are plain folders::
|
|
2
|
+
|
|
3
|
+
my-project/
|
|
4
|
+
project.json
|
|
5
|
+
datasets/*.jsonl
|
|
6
|
+
experiments/<id>/spec.json
|
|
7
|
+
experiments/<id>/results.parquet
|
|
8
|
+
experiments/<id>/summary.json
|
|
9
|
+
experiments/<id>/manifest.json
|
|
10
|
+
.gitignore
|
|
11
|
+
|
|
12
|
+
Nothing in a project folder refers to the machine it was made on: paths are project-relative.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import contextlib
|
|
18
|
+
import json
|
|
19
|
+
import math
|
|
20
|
+
import os
|
|
21
|
+
import re
|
|
22
|
+
import shutil
|
|
23
|
+
import uuid
|
|
24
|
+
from datetime import UTC, datetime
|
|
25
|
+
from importlib import resources
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
import platformdirs
|
|
30
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
31
|
+
|
|
32
|
+
from logogram.datasets import DatasetError, file_sha256, load_dataset
|
|
33
|
+
from logogram.fileio import write_text_atomic
|
|
34
|
+
from logogram.schema import Manifest, RunListing, Summary
|
|
35
|
+
from logogram.spec import Spec, describe_experiment
|
|
36
|
+
|
|
37
|
+
PROJECT_FILE = "project.json"
|
|
38
|
+
GITIGNORE = "# Logogram caches\n.logogram/\n__pycache__/\n"
|
|
39
|
+
APP_NAME = "logogram"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ProjectError(ValueError):
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ProjectMeta(BaseModel):
|
|
47
|
+
model_config = ConfigDict(extra="allow")
|
|
48
|
+
|
|
49
|
+
logogram_project: int = 1
|
|
50
|
+
name: str = Field(min_length=1, max_length=200)
|
|
51
|
+
description: str = ""
|
|
52
|
+
created: str = ""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def now_iso() -> str:
|
|
56
|
+
return datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def slugify(text: str, max_len: int = 40) -> str:
|
|
60
|
+
slug = re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")
|
|
61
|
+
return (slug[:max_len].rstrip("-")) or "run"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class Project:
|
|
65
|
+
def __init__(self, root: Path, meta: ProjectMeta):
|
|
66
|
+
self.root = root
|
|
67
|
+
self.meta = meta
|
|
68
|
+
self.session_id = uuid.uuid4().hex
|
|
69
|
+
self.interrupted_runs: set[str] = set()
|
|
70
|
+
|
|
71
|
+
# -- locations ---------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def datasets_dir(self) -> Path:
|
|
75
|
+
return self.root / "datasets"
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def experiments_dir(self) -> Path:
|
|
79
|
+
return self.root / "experiments"
|
|
80
|
+
|
|
81
|
+
def run_dir(self, run_id: str) -> Path:
|
|
82
|
+
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", run_id):
|
|
83
|
+
raise ProjectError(f"Not a valid run id: {run_id!r}")
|
|
84
|
+
folder = self.experiments_dir / run_id
|
|
85
|
+
if not self.inside(folder):
|
|
86
|
+
raise ProjectError("The run folder links outside the project.")
|
|
87
|
+
return folder
|
|
88
|
+
|
|
89
|
+
def resolve_dataset(self, rel: str) -> Path:
|
|
90
|
+
return self.readable(self.root / rel)
|
|
91
|
+
|
|
92
|
+
def readable(self, path: Path) -> Path:
|
|
93
|
+
"""Resolve a regular project file before reading it, including shared run metadata."""
|
|
94
|
+
if not self.inside(path):
|
|
95
|
+
raise ProjectError(f"{path.name} links to a file outside the project.")
|
|
96
|
+
resolved = path.resolve()
|
|
97
|
+
if not resolved.is_file():
|
|
98
|
+
raise ProjectError(f"{path.name} isn't a regular file inside the project.")
|
|
99
|
+
return resolved
|
|
100
|
+
|
|
101
|
+
def read_json(self, path: Path, *, optional: bool = False) -> dict[str, Any] | None:
|
|
102
|
+
if optional and not os.path.lexists(path):
|
|
103
|
+
return None
|
|
104
|
+
data = json.loads(self.readable(path).read_text(encoding="utf-8"))
|
|
105
|
+
if not isinstance(data, dict):
|
|
106
|
+
raise ProjectError(f"{path.name} must contain a JSON object.")
|
|
107
|
+
return data
|
|
108
|
+
|
|
109
|
+
def inside(self, path: Path) -> bool:
|
|
110
|
+
"""Whether ``path``, after following symlinks, is inside the project folder."""
|
|
111
|
+
try:
|
|
112
|
+
return path.resolve().is_relative_to(self.root.resolve())
|
|
113
|
+
except (OSError, RuntimeError): # a symlink loop
|
|
114
|
+
return False
|
|
115
|
+
|
|
116
|
+
def writable(self, path: Path) -> Path:
|
|
117
|
+
"""Return ``path`` if writing it stays inside the project.
|
|
118
|
+
|
|
119
|
+
Projects are shared through git and archives, which keep symlinks, so a folder in a
|
|
120
|
+
project could point anywhere. Files are only written into folders that really are part of
|
|
121
|
+
the project (and through ``fileio``, which never follows a symlink at the file itself).
|
|
122
|
+
"""
|
|
123
|
+
if not self.inside(path.parent):
|
|
124
|
+
where = path.parent.relative_to(self.root).as_posix()
|
|
125
|
+
raise ProjectError(
|
|
126
|
+
f"{where} links to a folder outside the project, so Logogram won't write there."
|
|
127
|
+
)
|
|
128
|
+
return path
|
|
129
|
+
|
|
130
|
+
def prepare_run_dir(self, run_id: str, *, new: bool = False) -> Path:
|
|
131
|
+
"""Create (or reuse) ``experiments/<run_id>`` and check that it is inside the project."""
|
|
132
|
+
folder = self.run_dir(run_id)
|
|
133
|
+
self.experiments_dir.mkdir(exist_ok=True)
|
|
134
|
+
self.writable(folder) # experiments/ itself is part of the project
|
|
135
|
+
folder.mkdir(exist_ok=not new)
|
|
136
|
+
return self.writable(folder / "spec.json").parent # and so is the run's folder
|
|
137
|
+
|
|
138
|
+
def new_run_dir(self, name: str) -> tuple[str, Path]:
|
|
139
|
+
"""Create a fresh ``experiments/<id>`` folder for a run or draft called ``name``."""
|
|
140
|
+
for _ in range(100):
|
|
141
|
+
run_id = self.new_run_id(name)
|
|
142
|
+
try:
|
|
143
|
+
return run_id, self.prepare_run_dir(run_id, new=True)
|
|
144
|
+
except FileExistsError:
|
|
145
|
+
continue # taken in the meantime
|
|
146
|
+
raise ProjectError("Couldn't create a folder for the run.")
|
|
147
|
+
|
|
148
|
+
def dataset_file(self, name: str) -> Path:
|
|
149
|
+
"""Where a dataset called ``name`` is written (``name`` is checked by the caller)."""
|
|
150
|
+
self.datasets_dir.mkdir(exist_ok=True)
|
|
151
|
+
return self.writable(self.datasets_dir / name)
|
|
152
|
+
|
|
153
|
+
# -- open / create -----------------------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
@classmethod
|
|
156
|
+
def open(cls, path: str | Path) -> Project:
|
|
157
|
+
root = Path(path).expanduser().resolve()
|
|
158
|
+
meta_path = root / PROJECT_FILE
|
|
159
|
+
if not meta_path.is_file():
|
|
160
|
+
raise ProjectError(
|
|
161
|
+
f"{root.name or root} isn't a Logogram project (there's no project.json). "
|
|
162
|
+
"Create a project there instead."
|
|
163
|
+
)
|
|
164
|
+
try:
|
|
165
|
+
cls(root, ProjectMeta(name="Project")).readable(meta_path)
|
|
166
|
+
meta = ProjectMeta.model_validate_json(meta_path.read_text(encoding="utf-8"))
|
|
167
|
+
except Exception as exc: # noqa: BLE001
|
|
168
|
+
raise ProjectError(f"project.json can't be read: {exc}") from exc
|
|
169
|
+
project = cls(root, meta)
|
|
170
|
+
project.ensure_layout()
|
|
171
|
+
return project
|
|
172
|
+
|
|
173
|
+
@classmethod
|
|
174
|
+
def create(cls, parent: str | Path, name: str, folder: str | None = None) -> Project:
|
|
175
|
+
name = name.strip()
|
|
176
|
+
if not name:
|
|
177
|
+
raise ProjectError("Give the project a name.")
|
|
178
|
+
parent_path = Path(parent).expanduser().resolve()
|
|
179
|
+
root = parent_path / (folder or slugify(name, 60))
|
|
180
|
+
if (root / PROJECT_FILE).exists():
|
|
181
|
+
raise ProjectError(f"A project already exists in {root.name}. Open it instead.")
|
|
182
|
+
try:
|
|
183
|
+
if root.exists() and any(root.iterdir()):
|
|
184
|
+
raise ProjectError(f"The folder {root.name} already exists and isn't empty.")
|
|
185
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
186
|
+
meta = ProjectMeta(name=name, created=now_iso())
|
|
187
|
+
write_text_atomic(root / PROJECT_FILE, json.dumps(meta.model_dump(), indent=2) + "\n")
|
|
188
|
+
project = cls(root, meta)
|
|
189
|
+
project.ensure_layout()
|
|
190
|
+
except OSError as exc:
|
|
191
|
+
raise ProjectError(
|
|
192
|
+
f"Logogram can't create a project in {parent_path}: {exc.strerror or exc}. "
|
|
193
|
+
"Choose a folder you can write to."
|
|
194
|
+
) from exc
|
|
195
|
+
return project
|
|
196
|
+
|
|
197
|
+
@staticmethod
|
|
198
|
+
def find_root(start: Path) -> Path | None:
|
|
199
|
+
start = start.resolve()
|
|
200
|
+
for candidate in [start, *start.parents]:
|
|
201
|
+
if (candidate / PROJECT_FILE).is_file():
|
|
202
|
+
return candidate
|
|
203
|
+
return None
|
|
204
|
+
|
|
205
|
+
def ensure_layout(self) -> None:
|
|
206
|
+
"""Add missing folders and the .gitignore. A read-only project still opens."""
|
|
207
|
+
with contextlib.suppress(OSError):
|
|
208
|
+
self.datasets_dir.mkdir(exist_ok=True)
|
|
209
|
+
self.experiments_dir.mkdir(exist_ok=True)
|
|
210
|
+
gitignore = self.root / ".gitignore"
|
|
211
|
+
if not os.path.lexists(gitignore): # not even a dangling symlink
|
|
212
|
+
write_text_atomic(gitignore, GITIGNORE)
|
|
213
|
+
|
|
214
|
+
# -- contents ----------------------------------------------------------------------------
|
|
215
|
+
|
|
216
|
+
def list_datasets(self) -> list[dict[str, Any]]:
|
|
217
|
+
out: list[dict[str, Any]] = []
|
|
218
|
+
if not self.inside(self.datasets_dir) or not self.datasets_dir.is_dir():
|
|
219
|
+
return out
|
|
220
|
+
for path in sorted(self.datasets_dir.glob("*.jsonl")):
|
|
221
|
+
entry: dict[str, Any] = {
|
|
222
|
+
"name": path.name,
|
|
223
|
+
"path": f"datasets/{path.name}",
|
|
224
|
+
"modified": _mtime_iso(path),
|
|
225
|
+
}
|
|
226
|
+
try:
|
|
227
|
+
records = load_dataset(self.readable(path))
|
|
228
|
+
entry["n"] = len(records)
|
|
229
|
+
entry["has_positions"] = all(r.positions for r in records)
|
|
230
|
+
entry["sha256"] = file_sha256(path)
|
|
231
|
+
except (DatasetError, ProjectError, OSError) as exc:
|
|
232
|
+
entry["error"] = str(exc)
|
|
233
|
+
out.append(entry)
|
|
234
|
+
return out
|
|
235
|
+
|
|
236
|
+
def new_run_id(self, name: str) -> str:
|
|
237
|
+
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
238
|
+
base = f"{stamp}-{slugify(name)}"
|
|
239
|
+
candidate, i = base, 2
|
|
240
|
+
while (self.experiments_dir / candidate).exists():
|
|
241
|
+
candidate = f"{base}-{i}"
|
|
242
|
+
i += 1
|
|
243
|
+
return candidate
|
|
244
|
+
|
|
245
|
+
def list_runs(self) -> list[RunListing]:
|
|
246
|
+
runs = []
|
|
247
|
+
if not self.inside(self.experiments_dir) or not self.experiments_dir.is_dir():
|
|
248
|
+
return runs
|
|
249
|
+
for folder in self.experiments_dir.iterdir():
|
|
250
|
+
if self.inside(folder) and folder.is_dir():
|
|
251
|
+
listing = self.run_listing(folder.name)
|
|
252
|
+
if listing is not None:
|
|
253
|
+
runs.append(listing)
|
|
254
|
+
runs.sort(key=lambda r: (r.created or "", r.id), reverse=True)
|
|
255
|
+
return runs
|
|
256
|
+
|
|
257
|
+
def run_listing(self, run_id: str) -> RunListing | None:
|
|
258
|
+
try:
|
|
259
|
+
folder = self.run_dir(run_id)
|
|
260
|
+
spec = Spec.from_path(self.readable(folder / "spec.json"))
|
|
261
|
+
except Exception: # noqa: BLE001 - unreadable specs are skipped in history
|
|
262
|
+
return None
|
|
263
|
+
error = None
|
|
264
|
+
try:
|
|
265
|
+
manifest = self.read_json(folder / "manifest.json", optional=True) or {}
|
|
266
|
+
if manifest:
|
|
267
|
+
manifest = Manifest.model_validate(manifest).model_dump(mode="json")
|
|
268
|
+
summary_meta = self.read_json(folder / "summary.json", optional=True) or {}
|
|
269
|
+
if summary_meta:
|
|
270
|
+
summary_meta = Summary.model_validate(summary_meta).model_dump(mode="json")
|
|
271
|
+
n = summary_meta.get("n_prompts")
|
|
272
|
+
layout = summary_meta.get("layout") or {}
|
|
273
|
+
if (n is not None and (type(n) is not int or n < 1)) or not isinstance(layout, dict):
|
|
274
|
+
raise ValueError("Invalid summary metadata")
|
|
275
|
+
if layout.get("kind") is not None and not isinstance(layout["kind"], str):
|
|
276
|
+
raise ValueError("Invalid layout kind")
|
|
277
|
+
except (ValueError, OSError):
|
|
278
|
+
manifest, summary_meta = {}, {}
|
|
279
|
+
error = "Run metadata is unreadable or invalid. Restore its manifest and summary from a backup, or rerun the spec."
|
|
280
|
+
status = manifest.get("status") or "draft"
|
|
281
|
+
if run_id in self.interrupted_runs:
|
|
282
|
+
status = "failed"
|
|
283
|
+
error = "The server stopped before this run finished. Rerun its saved spec."
|
|
284
|
+
if error or (
|
|
285
|
+
status == "finished"
|
|
286
|
+
and (
|
|
287
|
+
not self.inside(folder / "results.parquet")
|
|
288
|
+
or not (folder / "results.parquet").is_file()
|
|
289
|
+
)
|
|
290
|
+
):
|
|
291
|
+
status = "failed"
|
|
292
|
+
error = error or "Run results are missing or outside the project. Rerun the spec."
|
|
293
|
+
return RunListing(
|
|
294
|
+
id=run_id,
|
|
295
|
+
name=spec.name,
|
|
296
|
+
description=describe_experiment(spec),
|
|
297
|
+
status=status,
|
|
298
|
+
created=manifest.get("started_at") or _mtime_iso(folder / "spec.json"),
|
|
299
|
+
finished=manifest.get("finished_at"),
|
|
300
|
+
wall_time_s=manifest.get("wall_time_s"),
|
|
301
|
+
n_prompts=summary_meta.get("n_prompts"),
|
|
302
|
+
layout_kind=(summary_meta.get("layout") or {}).get("kind"),
|
|
303
|
+
model_id=spec.model.id,
|
|
304
|
+
dataset=spec.dataset.path,
|
|
305
|
+
experiment=spec.experiment.model_dump(),
|
|
306
|
+
scope=spec.scope.model_dump(),
|
|
307
|
+
derived_from=manifest.get("derived_from"),
|
|
308
|
+
error=error or manifest.get("error"),
|
|
309
|
+
profile=layer_profile(summary_meta) if status == "finished" else None,
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
def last_modified(self) -> str:
|
|
313
|
+
latest = (self.root / PROJECT_FILE).stat().st_mtime
|
|
314
|
+
for sub in (self.datasets_dir, self.experiments_dir):
|
|
315
|
+
try:
|
|
316
|
+
latest = max(latest, sub.stat().st_mtime)
|
|
317
|
+
for child in sub.iterdir() if self.inside(sub) else ():
|
|
318
|
+
with contextlib.suppress(OSError): # e.g. a dangling symlink
|
|
319
|
+
latest = max(latest, child.lstat().st_mtime)
|
|
320
|
+
except OSError:
|
|
321
|
+
continue
|
|
322
|
+
return datetime.fromtimestamp(latest, UTC).replace(microsecond=0).isoformat()
|
|
323
|
+
|
|
324
|
+
def to_dict(self) -> dict[str, Any]:
|
|
325
|
+
return {
|
|
326
|
+
"name": self.meta.name,
|
|
327
|
+
"description": self.meta.description,
|
|
328
|
+
"path": str(self.root),
|
|
329
|
+
"session_id": self.session_id,
|
|
330
|
+
"datasets": self.list_datasets(),
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def layer_profile(summary: dict[str, Any]) -> list[float] | None:
|
|
335
|
+
"""Per layer, the measured site with the largest absolute normalized effect, with its sign.
|
|
336
|
+
|
|
337
|
+
The app writes each run's logogram from this: layers clockwise, swelling where effects are
|
|
338
|
+
strong. Layers without a measured value are 0.
|
|
339
|
+
"""
|
|
340
|
+
sites = summary.get("sites") or []
|
|
341
|
+
layers = [s.get("layer") for s in sites if isinstance(s.get("layer"), int)]
|
|
342
|
+
if not layers:
|
|
343
|
+
return None
|
|
344
|
+
model = summary.get("model") or {}
|
|
345
|
+
n_layers = model.get("n_layers") if isinstance(model.get("n_layers"), int) else max(layers) + 1
|
|
346
|
+
out = [0.0] * max(n_layers, max(layers) + 1)
|
|
347
|
+
for site in sites:
|
|
348
|
+
layer = site.get("layer")
|
|
349
|
+
value = (site.get("effect") or {}).get("mean")
|
|
350
|
+
if not isinstance(layer, int) or layer < 0 or not isinstance(value, int | float):
|
|
351
|
+
continue
|
|
352
|
+
if (site.get("variant") or {}).get("control"):
|
|
353
|
+
continue # a control writes nothing: it is what the run is measured against
|
|
354
|
+
if math.isfinite(value) and abs(value) > abs(out[layer]):
|
|
355
|
+
out[layer] = float(value)
|
|
356
|
+
return [round(v, 4) for v in out]
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _mtime_iso(path: Path) -> str | None:
|
|
360
|
+
try:
|
|
361
|
+
return datetime.fromtimestamp(path.lstat().st_mtime, UTC).replace(microsecond=0).isoformat()
|
|
362
|
+
except OSError:
|
|
363
|
+
return None
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _read_json(path: Path, keys: tuple[str, ...] | None = None) -> dict[str, Any] | None:
|
|
367
|
+
try:
|
|
368
|
+
if not path.is_file(): # also skips devices and pipes, which could block or never end
|
|
369
|
+
return None
|
|
370
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
371
|
+
except (OSError, ValueError):
|
|
372
|
+
return None
|
|
373
|
+
if keys is not None and isinstance(data, dict):
|
|
374
|
+
return {k: data.get(k) for k in keys}
|
|
375
|
+
return data if isinstance(data, dict) else None
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
# -- recent projects and the example -----------------------------------------------------------
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def config_dir() -> Path:
|
|
382
|
+
return platformdirs.user_config_path(APP_NAME)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def recent_file() -> Path:
|
|
386
|
+
return config_dir() / "recent.json"
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def load_recent() -> list[dict[str, Any]]:
|
|
390
|
+
data = _read_json(recent_file())
|
|
391
|
+
items = data.get("projects", []) if data else []
|
|
392
|
+
out = []
|
|
393
|
+
for item in items:
|
|
394
|
+
path = Path(item.get("path", ""))
|
|
395
|
+
try:
|
|
396
|
+
if not (path / PROJECT_FILE).is_file():
|
|
397
|
+
continue
|
|
398
|
+
project = Project.open(path)
|
|
399
|
+
out.append(
|
|
400
|
+
{
|
|
401
|
+
"path": str(path),
|
|
402
|
+
"name": project.meta.name,
|
|
403
|
+
"opened": item.get("opened"),
|
|
404
|
+
"modified": project.last_modified(),
|
|
405
|
+
}
|
|
406
|
+
)
|
|
407
|
+
except (ProjectError, OSError):
|
|
408
|
+
continue # moved, deleted or unreadable: leave it out of the list
|
|
409
|
+
return out
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def remember_recent(project: Project) -> None:
|
|
413
|
+
items = [i for i in load_recent() if i["path"] != str(project.root)]
|
|
414
|
+
items.insert(0, {"path": str(project.root), "opened": now_iso()})
|
|
415
|
+
recent_file().parent.mkdir(parents=True, exist_ok=True)
|
|
416
|
+
payload = {"projects": [{"path": i["path"], "opened": i.get("opened")} for i in items[:12]]}
|
|
417
|
+
write_text_atomic(recent_file(), json.dumps(payload, indent=2) + "\n")
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def default_projects_parent() -> Path:
|
|
421
|
+
return platformdirs.user_documents_path() / "Logogram"
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def example_source() -> Path:
|
|
425
|
+
return Path(str(resources.files("logogram") / "examples" / "ioi-gpt2"))
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def open_example(parent: Path | None = None) -> Project:
|
|
429
|
+
"""Copy the bundled example into a writable folder (once) and open it."""
|
|
430
|
+
target = (parent or default_projects_parent()) / "ioi-example"
|
|
431
|
+
if (target / PROJECT_FILE).is_file():
|
|
432
|
+
return Project.open(target)
|
|
433
|
+
try:
|
|
434
|
+
if target.exists() and any(target.iterdir()):
|
|
435
|
+
raise ProjectError(
|
|
436
|
+
f"{target} exists and isn't a Logogram project. Move it aside first."
|
|
437
|
+
)
|
|
438
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
439
|
+
shutil.copytree(example_source(), target, dirs_exist_ok=True)
|
|
440
|
+
except OSError as exc:
|
|
441
|
+
raise ProjectError(
|
|
442
|
+
f"Logogram can't copy the example to {target}: {exc.strerror or exc}."
|
|
443
|
+
) from exc
|
|
444
|
+
return Project.open(target)
|