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.
Files changed (54) hide show
  1. logogram/__init__.py +6 -0
  2. logogram/__main__.py +5 -0
  3. logogram/analysis.py +419 -0
  4. logogram/atp.py +120 -0
  5. logogram/backends/__init__.py +5 -0
  6. logogram/backends/base.py +202 -0
  7. logogram/backends/hub.py +375 -0
  8. logogram/backends/saes.py +277 -0
  9. logogram/backends/transformer_lens.py +872 -0
  10. logogram/cli.py +496 -0
  11. logogram/compare.py +177 -0
  12. logogram/datasets.py +159 -0
  13. logogram/direct.py +193 -0
  14. logogram/engine.py +550 -0
  15. logogram/examples/ioi-gpt2/.gitignore +3 -0
  16. logogram/examples/ioi-gpt2/datasets/ioi.jsonl +32 -0
  17. logogram/examples/ioi-gpt2/experiments/ioi-head-patching/spec.json +42 -0
  18. logogram/examples/ioi-gpt2/project.json +6 -0
  19. logogram/exports.py +33 -0
  20. logogram/features.py +368 -0
  21. logogram/fileio.py +63 -0
  22. logogram/ioi.py +220 -0
  23. logogram/paths.py +204 -0
  24. logogram/project.py +444 -0
  25. logogram/prompts.py +204 -0
  26. logogram/research.py +84 -0
  27. logogram/results.py +240 -0
  28. logogram/runner.py +396 -0
  29. logogram/runs.py +98 -0
  30. logogram/sae.py +161 -0
  31. logogram/schema.py +302 -0
  32. logogram/server/__init__.py +1 -0
  33. logogram/server/app.py +1083 -0
  34. logogram/server/models.py +426 -0
  35. logogram/server/security.py +212 -0
  36. logogram/server/state.py +585 -0
  37. logogram/sites.py +249 -0
  38. logogram/spec.py +518 -0
  39. logogram/stats.py +171 -0
  40. logogram/steering.py +258 -0
  41. logogram/system.py +379 -0
  42. logogram/updates.py +194 -0
  43. logogram/verify.py +39 -0
  44. logogram/web_dist/assets/index-BvCU-2uy.js +54 -0
  45. logogram/web_dist/assets/index-DTr8_ucV.css +1 -0
  46. logogram/web_dist/assets/instrument-sans-latin-ext-standard-normal-C5E2Gvlv.woff2 +0 -0
  47. logogram/web_dist/assets/instrument-sans-latin-standard-normal-BVScPF0l.woff2 +0 -0
  48. logogram/web_dist/favicon.svg +1 -0
  49. logogram/web_dist/index.html +15 -0
  50. logogram-0.1.0.dist-info/METADATA +550 -0
  51. logogram-0.1.0.dist-info/RECORD +54 -0
  52. logogram-0.1.0.dist-info/WHEEL +4 -0
  53. logogram-0.1.0.dist-info/entry_points.txt +2 -0
  54. 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)