quantlab-factor-agent 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 (72) hide show
  1. factor_agent/KUNQUANT_LICENSE +201 -0
  2. factor_agent/__init__.py +8 -0
  3. factor_agent/_acceptance_model.py +93 -0
  4. factor_agent/_admission.py +168 -0
  5. factor_agent/_expression_fold.py +48 -0
  6. factor_agent/_feedback.py +399 -0
  7. factor_agent/_max_drawdown.py +51 -0
  8. factor_agent/_model_cache.py +100 -0
  9. factor_agent/_scheduler.py +35 -0
  10. factor_agent/acceptance.py +808 -0
  11. factor_agent/accepted/__init__.py +1 -0
  12. factor_agent/aggregations.py +122 -0
  13. factor_agent/campaign.py +797 -0
  14. factor_agent/candidate.py +51 -0
  15. factor_agent/config.py +651 -0
  16. factor_agent/derived.py +98 -0
  17. factor_agent/evaluator.py +1096 -0
  18. factor_agent/events.py +343 -0
  19. factor_agent/exported.py +177 -0
  20. factor_agent/gate/__init__.py +359 -0
  21. factor_agent/gate/_canonical.py +331 -0
  22. factor_agent/gate/_checks.py +85 -0
  23. factor_agent/gate/_constraints.py +85 -0
  24. factor_agent/gate/_dimension.py +177 -0
  25. factor_agent/gate/_originality.py +146 -0
  26. factor_agent/gate/_sandbox.py +105 -0
  27. factor_agent/gate/graph.py +223 -0
  28. factor_agent/gate/stages.py +383 -0
  29. factor_agent/gate/vocabulary.py +1097 -0
  30. factor_agent/ledger.py +85 -0
  31. factor_agent/llm/__init__.py +0 -0
  32. factor_agent/llm/_common.py +18 -0
  33. factor_agent/llm/_raw.py +78 -0
  34. factor_agent/llm/anthropic.py +100 -0
  35. factor_agent/llm/base.py +124 -0
  36. factor_agent/llm/claude_code.py +101 -0
  37. factor_agent/llm/log.py +74 -0
  38. factor_agent/llm/openai_compatible.py +113 -0
  39. factor_agent/llm/replay.py +59 -0
  40. factor_agent/llm/scripted.py +39 -0
  41. factor_agent/period_lag.py +91 -0
  42. factor_agent/prompts.py +557 -0
  43. factor_agent/researcher.py +615 -0
  44. factor_agent/scoring.py +143 -0
  45. factor_agent/segments.py +184 -0
  46. factor_agent/session.py +500 -0
  47. factor_agent/snapshot.py +219 -0
  48. factor_agent/web/__init__.py +7 -0
  49. factor_agent/web/_acceptance.py +145 -0
  50. factor_agent/web/_config.py +51 -0
  51. factor_agent/web/_diagnosis.py +110 -0
  52. factor_agent/web/_files.py +78 -0
  53. factor_agent/web/_health.py +207 -0
  54. factor_agent/web/_ic.py +27 -0
  55. factor_agent/web/_ledger.py +293 -0
  56. factor_agent/web/_memory.py +42 -0
  57. factor_agent/web/_results.py +77 -0
  58. factor_agent/web/_round.py +219 -0
  59. factor_agent/web/_trials.py +112 -0
  60. factor_agent/web/app.py +118 -0
  61. factor_agent/web/run_view.py +329 -0
  62. factor_agent/web/static/assets/index-9QjjJgl3.css +1 -0
  63. factor_agent/web/static/assets/index-iOE9ei_S.js +74 -0
  64. factor_agent/web/static/index.html +13 -0
  65. factor_agent/zoo.py +136 -0
  66. quantlab_factor_agent-0.1.0.dist-info/METADATA +172 -0
  67. quantlab_factor_agent-0.1.0.dist-info/RECORD +72 -0
  68. quantlab_factor_agent-0.1.0.dist-info/WHEEL +5 -0
  69. quantlab_factor_agent-0.1.0.dist-info/entry_points.txt +2 -0
  70. quantlab_factor_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
  71. quantlab_factor_agent-0.1.0.dist-info/licenses/factor_agent/KUNQUANT_LICENSE +201 -0
  72. quantlab_factor_agent-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,219 @@
1
+ """The RunSnapshot: the files a run searched with and the code it ran, kept in its run directory.
2
+
3
+ A run's search space is what its files say (``SearchFiles``): the field
4
+ files and the operator file and the replaced prompt templates. On a first
5
+ start ``open`` copies each file into ``<run_dir>/snapshot/<name>`` and records
6
+ its hash and the code versions (the commits of this package and of
7
+ quantlab) in ``<run_dir>/snapshot.json``. On a resume a file whose name or
8
+ content differs from the snapshot is an error, since a new search space
9
+ needs a new run directory; a code version that differs only warns, so a
10
+ bug-fix resume is allowed but visible, and every code version a run was
11
+ opened with is kept in order.
12
+
13
+ The run reads the snapshot's copies (``search_files``), not the files it was given,
14
+ and ``read`` opens a run's snapshot without recording anything, for
15
+ Acceptance and for a replay of the run.
16
+ """
17
+
18
+ import hashlib
19
+ import json
20
+ import shutil
21
+ import warnings
22
+ from collections.abc import Mapping
23
+ from dataclasses import dataclass, field
24
+ from datetime import datetime, timezone
25
+ from pathlib import Path
26
+
27
+ #: The code versions compared on a resume (``segments.code_version`` holds them and quantlab's version).
28
+ COMPARED_VERSIONS = ("factor_agent", "quantlab")
29
+
30
+
31
+ class SnapshotMismatch(ValueError):
32
+ """A resume was given files that differ from the run's snapshot."""
33
+
34
+
35
+ class CodeVersionChanged(UserWarning):
36
+ """A resume runs other code than the run was last opened with."""
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class SearchFiles:
41
+ """The files a run searches with: the field files, the operator file and the replaced templates.
42
+
43
+ ``prompts`` maps a template's name to the file that replaces it. In a
44
+ snapshot a field file is named ``fields/<file name>``, the operator file
45
+ ``operators/<file name>`` and a replaced template
46
+ ``prompts/<template>/<file name>`` (``names``, ``from_names``).
47
+ """
48
+
49
+ fields: tuple
50
+ operators: str | Path
51
+ prompts: Mapping[str, str | Path] = field(default_factory=dict)
52
+
53
+ def __post_init__(self):
54
+ object.__setattr__(self, "fields", tuple(self.fields))
55
+
56
+ def names(self) -> dict[str, str | Path]:
57
+ """Every file by its name in a snapshot, field files first, then the operator file and the templates.
58
+
59
+ Raises
60
+ ------
61
+ ValueError
62
+ If two field files have the same file name.
63
+ """
64
+ files = {f"fields/{Path(path).name}": path for path in self.fields}
65
+ if len(files) != len(self.fields):
66
+ raise ValueError(f"run snapshot: the field files {[str(f) for f in self.fields]} repeat a file name")
67
+ templates = {f"prompts/{name}/{Path(path).name}": path for name, path in self.prompts.items()}
68
+ return {**files, f"operators/{Path(self.operators).name}": self.operators, **templates}
69
+
70
+ @classmethod
71
+ def from_names(cls, files: Mapping[str, str | Path]) -> "SearchFiles":
72
+ """The files of a snapshot's ``files`` (name to path), as ``names`` names them."""
73
+ [operators] = [path for name, path in files.items() if name.startswith("operators/")]
74
+ return cls(
75
+ fields=tuple(path for name, path in files.items() if name.startswith("fields/")),
76
+ operators=operators,
77
+ prompts={name.split("/")[1]: path for name, path in files.items() if name.startswith("prompts/")},
78
+ )
79
+
80
+
81
+ class RunSnapshot:
82
+ """The snapshot of a run directory; build it with ``open`` or ``read``.
83
+
84
+ ``files`` maps each name to its copy, in the order the run was first
85
+ given them; ``versions`` lists the code versions of every opening.
86
+ """
87
+
88
+ def __init__(self, run_dir: Path, manifest: dict):
89
+ self.run_dir = run_dir
90
+ self.files = {name: run_dir / "snapshot" / name for name in manifest["files"]}
91
+ self.versions: list[dict] = manifest["versions"]
92
+ self.settings: dict = manifest.get("settings", {})
93
+
94
+ @classmethod
95
+ def open(
96
+ cls, run_dir: str | Path, search: SearchFiles, versions: Mapping[str, str], settings: Mapping | None = None,
97
+ ) -> "RunSnapshot":
98
+ """Open ``run_dir`` with the files of ``search``, the code ``versions`` it runs on and its ``settings``.
99
+
100
+ A first start copies the files, under their ``SearchFiles.names``, and
101
+ records ``settings`` (by section, the config settings the search
102
+ depends on besides the files, such as ``{"context": {"recent_rounds": 5}}``
103
+ and, with an intraday family, ``{"intraday": {"dataset": ...}}``); a resume
104
+ checks the files against the copies and the settings against the
105
+ recorded ones. Either way ``versions`` is appended to the recorded versions.
106
+
107
+ Raises
108
+ ------
109
+ SnapshotMismatch
110
+ On a resume, naming every file added, removed or changed and every setting changed since the first start.
111
+ ValueError
112
+ If two field files have the same file name.
113
+
114
+ Warns
115
+ -----
116
+ CodeVersionChanged
117
+ On a resume whose ``versions`` differ from the last recorded ones.
118
+ """
119
+ run_dir = Path(run_dir)
120
+ files = search.names()
121
+ manifest_path = run_dir / "snapshot.json"
122
+ hashes = {name: _hash(Path(path)) for name, path in files.items()}
123
+ if manifest_path.exists():
124
+ manifest = json.loads(manifest_path.read_text())
125
+ _check(run_dir, manifest["files"], hashes)
126
+ _check_settings(run_dir, manifest.get("settings", {}), dict(settings or {}))
127
+ _warn(manifest["versions"][-1], versions)
128
+ else:
129
+ manifest = {"files": hashes, "settings": dict(settings or {}), "versions": []}
130
+ for name, path in files.items():
131
+ copy = run_dir / "snapshot" / name
132
+ copy.parent.mkdir(parents=True, exist_ok=True)
133
+ shutil.copyfile(path, copy)
134
+ manifest["versions"].append({**versions, "opened_at": datetime.now(timezone.utc).isoformat(timespec="seconds")})
135
+ tmp = manifest_path.with_suffix(".tmp")
136
+ tmp.write_text(json.dumps(manifest, indent=2) + "\n")
137
+ tmp.replace(manifest_path)
138
+ return cls(run_dir, manifest)
139
+
140
+ @classmethod
141
+ def read(cls, run_dir: str | Path) -> "RunSnapshot":
142
+ """The snapshot of ``run_dir`` as it is, recording nothing.
143
+
144
+ Raises
145
+ ------
146
+ FileNotFoundError
147
+ If the run directory has no snapshot.
148
+ """
149
+ run_dir = Path(run_dir)
150
+ manifest_path = run_dir / "snapshot.json"
151
+ if not manifest_path.exists():
152
+ raise FileNotFoundError(f"run snapshot: {run_dir} has no snapshot ({manifest_path})")
153
+ return cls(run_dir, json.loads(manifest_path.read_text()))
154
+
155
+ @staticmethod
156
+ def exists(run_dir: str | Path) -> bool:
157
+ """Whether ``run_dir`` has a snapshot."""
158
+ return (Path(run_dir) / "snapshot.json").exists()
159
+
160
+ def path(self, name: str) -> Path:
161
+ """The snapshot's copy of the file ``name``.
162
+
163
+ Raises
164
+ ------
165
+ KeyError
166
+ If the snapshot holds no file of that name.
167
+ """
168
+ if name not in self.files:
169
+ raise KeyError(f"run snapshot of {self.run_dir}: no file {name!r}; it holds {list(self.files)}")
170
+ return self.files[name]
171
+
172
+ @property
173
+ def search_files(self) -> SearchFiles:
174
+ """The snapshot's copies, as the files the run searches with."""
175
+ return SearchFiles.from_names(self.files)
176
+
177
+
178
+ def _hash(path: Path) -> str:
179
+ return hashlib.sha256(path.read_bytes()).hexdigest()
180
+
181
+
182
+ def _check(run_dir: Path, recorded: dict, given: dict) -> None:
183
+ """Raise ``SnapshotMismatch`` naming every file of ``given`` that is not the one ``recorded``."""
184
+ problems = [f"{name} was added" for name in given if name not in recorded]
185
+ problems += [f"{name} was removed" for name in recorded if name not in given]
186
+ problems += [f"{name} changed" for name in given if name in recorded and given[name] != recorded[name]]
187
+ if problems:
188
+ raise SnapshotMismatch(
189
+ f"run snapshot of {run_dir}: the run's files differ from the ones it started with: "
190
+ f"{'; '.join(problems)}. A changed search space needs a new run directory."
191
+ )
192
+
193
+
194
+ def _check_settings(run_dir: Path, recorded: dict, given: dict) -> None:
195
+ """Raise ``SnapshotMismatch`` naming every setting of ``given`` that is not the one ``recorded``."""
196
+ before, now = _flat(recorded), _flat(given)
197
+ changed = [
198
+ f"{name} was {before.get(name, 'not recorded')}, now {now.get(name, 'not given')}"
199
+ for name in sorted(before.keys() | now.keys()) if before.get(name) != now.get(name)
200
+ ]
201
+ if changed:
202
+ raise SnapshotMismatch(
203
+ f"run snapshot of {run_dir}: the run's settings differ from the ones it started with: "
204
+ f"{'; '.join(changed)}. A changed search space needs a new run directory."
205
+ )
206
+
207
+
208
+ def _flat(settings: dict) -> dict:
209
+ """``settings`` by section as one mapping from ``"section.key"`` to the value."""
210
+ return {f"{section}.{key}": value for section, values in settings.items() for key, value in values.items()}
211
+
212
+
213
+ def _warn(last: dict, versions: Mapping[str, str]) -> None:
214
+ changed = [f"{key} {last.get(key)} -> {versions.get(key)}" for key in COMPARED_VERSIONS if last.get(key) != versions.get(key)]
215
+ if changed:
216
+ warnings.warn(
217
+ f"run snapshot: the run resumes on other code than it was last opened with: {'; '.join(changed)}",
218
+ CodeVersionChanged, stacklevel=3,
219
+ )
@@ -0,0 +1,7 @@
1
+ """The web frontend: a read-only view of the run directories under a folder, served on localhost.
2
+
3
+ ``run_view`` reads run directories (``list_runs``, ``RunView.read``) and needs
4
+ nothing beyond the campaign's own dependencies; ``app`` serves what it reads
5
+ as JSON routes and the page, and needs the ``web`` extra (FastAPI and uvicorn).
6
+ The frontend never writes to a run directory.
7
+ """
@@ -0,0 +1,145 @@
1
+ """What Acceptance has said of a run: its reports ``acceptance/<segment>[_<digest>].json`` and the hold-out lock's opens.
2
+
3
+ A report that records opens of the hold-out is a hold-out report. A report
4
+ written before the contribution table or the selection flag existed reads
5
+ with ``None`` for them: nothing it lacks is made up.
6
+ """
7
+
8
+ from dataclasses import dataclass
9
+ from datetime import datetime
10
+ from pathlib import Path
11
+
12
+ from factor_agent.web._files import read_json, utc
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class Contribution:
17
+ """One accepted factor's contribution to the model in one mode, from an Acceptance report's contribution table.
18
+
19
+ ``factor`` is the factor's label. ``mode`` is ``"drop_one"`` (the whole
20
+ selection against the selection without it) or ``"add_one"`` (the
21
+ baseline with only it against the baseline). ``difference`` holds, per
22
+ compared statistic, with the factor minus without it, seed-averaged;
23
+ ``agreeing`` the seeds whose own difference has the sign of that mean.
24
+ """
25
+
26
+ factor: str
27
+ mode: str
28
+ difference: dict[str, float | None]
29
+ agreeing: dict[str, int]
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class Comparison:
34
+ """One Acceptance report: the user's model with and without accepted factors, on validation or the hold-out.
35
+
36
+ ``name`` is the report's file name without ``.json`` (``validation``, or
37
+ ``validation_<digest>`` for a selection). ``holdout`` is true for a
38
+ report on the hold-out. ``factors`` holds the labels of the factors
39
+ added to the model. ``selection`` is true for a selection of fewer than
40
+ every accepted factor, ``None`` for a report written before the flag.
41
+ ``baseline`` and ``with_accepted`` hold each compared statistic averaged
42
+ over ``seeds``, ``difference`` the second minus the first. ``verdict``
43
+ is ``"improves"`` when the rank IC and the top-k excess return both
44
+ rise with the factors, ``"does_not_improve"`` when neither does,
45
+ ``"mixed"`` otherwise, ``None`` when either is undefined. ``forced`` is
46
+ true for a hold-out opened again past its lock. ``contribution`` is the
47
+ report's contribution table, the largest rank IC difference first:
48
+ empty when no contribution was asked for, ``None`` for a report written
49
+ before the table. ``written`` is the report file's time (UTC).
50
+ """
51
+
52
+ name: str
53
+ segment: str
54
+ holdout: bool
55
+ factors: tuple[str, ...]
56
+ selection: bool | None
57
+ seeds: tuple[int, ...]
58
+ top_k: int | None
59
+ train_window: tuple[str, ...]
60
+ test_window: tuple[str, ...]
61
+ baseline: dict[str, float | None]
62
+ with_accepted: dict[str, float | None]
63
+ difference: dict[str, float | None]
64
+ verdict: str | None
65
+ forced: bool
66
+ contribution: tuple[Contribution, ...] | None
67
+ code_version: str
68
+ written: datetime
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class HoldoutOpen:
73
+ """One open of the hold-out, as the hold-out lock records it: ``forced`` for every open after the first."""
74
+
75
+ opened_at: str
76
+ code_version: str
77
+ forced: bool
78
+
79
+
80
+ @dataclass(frozen=True)
81
+ class AcceptanceReports:
82
+ """What Acceptance has said of a run's accepted factors.
83
+
84
+ ``validation`` is the validation report on every accepted factor, or
85
+ when there is none the latest validation report of a selection;
86
+ ``holdout`` is the report of the latest open of the hold-out. Either is
87
+ ``None`` while Acceptance has not written it. ``reports`` holds every
88
+ report, the earliest written first; ``holdout_opens`` every open the
89
+ hold-out lock records, which may include an open that wrote no report.
90
+ """
91
+
92
+ validation: Comparison | None
93
+ holdout: Comparison | None
94
+ reports: tuple[Comparison, ...]
95
+ holdout_opens: tuple[HoldoutOpen, ...]
96
+
97
+
98
+ def _verdict(difference: dict) -> str | None:
99
+ """Whether the rank IC and the top-k excess return both rose, neither did, or one did."""
100
+ rank_ic, excess = difference.get("rank_ic"), difference.get("top_k_excess")
101
+ if rank_ic is None or excess is None:
102
+ return None
103
+ if rank_ic > 0 and excess > 0:
104
+ return "improves"
105
+ return "does_not_improve" if rank_ic <= 0 and excess <= 0 else "mixed"
106
+
107
+
108
+ def _comparison(path: Path) -> Comparison | None:
109
+ """The report at ``path``, ``None`` when it is not an Acceptance report."""
110
+ report = read_json(path)
111
+ if not isinstance(report, dict) or "difference" not in report:
112
+ return None
113
+ mean, difference = report.get("mean") or {}, report["difference"]
114
+ table = report.get("contribution")
115
+ return Comparison(
116
+ name=path.stem, segment=report.get("segment", path.stem), holdout=bool(report.get("holdout_opens")),
117
+ factors=tuple(report.get("accepted") or ()), selection=report.get("selection"),
118
+ seeds=tuple(report.get("seeds") or ()), top_k=report.get("top_k"),
119
+ train_window=tuple(report.get("train_window") or ()), test_window=tuple(report.get("test_window") or ()),
120
+ baseline=mean.get("baseline") or {}, with_accepted=mean.get("with_accepted") or {}, difference=difference,
121
+ verdict=_verdict(difference), forced=bool(report.get("forced")),
122
+ contribution=None if table is None else tuple(
123
+ Contribution(factor=row["factor"], mode=row["mode"], difference=row["difference"], agreeing=row["agreeing"])
124
+ for row in table
125
+ ),
126
+ code_version=(report.get("code_version") or {}).get("factor_agent") or "unknown",
127
+ written=utc(path.stat().st_mtime),
128
+ )
129
+
130
+
131
+ def acceptance(run_dir: Path) -> AcceptanceReports:
132
+ """The Acceptance reports, the earliest written first, and the hold-out lock's opens."""
133
+ found = [(path.stat().st_mtime_ns, path) for path in (run_dir / "acceptance").glob("*.json")]
134
+ reports = tuple(c for _, path in sorted(found) if (c := _comparison(path)))
135
+ validations = [c for c in reports if not c.holdout]
136
+ validation = next((c for c in validations if c.name == c.segment), validations[-1] if validations else None)
137
+ holdouts = [c for c in reports if c.holdout]
138
+ lock = read_json(run_dir / "holdout.lock") or {}
139
+ opens = tuple(
140
+ HoldoutOpen(opened_at=o["opened_at"], code_version=(o.get("code_version") or {}).get("factor_agent") or "unknown",
141
+ forced=bool(o.get("forced")))
142
+ for o in lock.get("opens") or ()
143
+ )
144
+ return AcceptanceReports(validation=validation, holdout=holdouts[-1] if holdouts else None, reports=reports,
145
+ holdout_opens=opens)
@@ -0,0 +1,51 @@
1
+ """The files a run was set up with: the config of each opening and the snapshot's files."""
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+
6
+ from factor_agent.web._files import LineChange, line_changes, read_json
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class ConfigCopy:
11
+ """The config text the run's ``opening``-th start or resume was given, opened at ``opened_at`` (ISO, UTC).
12
+
13
+ ``changes`` holds its lines and those of the opening before, in order,
14
+ each marked kept, added or removed; ``None`` for the earliest config kept.
15
+ """
16
+
17
+ opening: int
18
+ opened_at: str | None
19
+ text: str
20
+ changes: tuple[LineChange, ...] | None = None
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class SnapshotFile:
25
+ """A file of the run's snapshot by its name there (``fields/<file>``, ``operators/<file>``, ``prompts/<file>``)."""
26
+
27
+ name: str
28
+ text: str
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class RunFiles:
33
+ """The configs of a run, the latest opening first, empty for a run written before configs were kept, and its snapshot's files."""
34
+
35
+ configs: tuple[ConfigCopy, ...]
36
+ snapshot: tuple[SnapshotFile, ...]
37
+
38
+
39
+ def read_files(run_dir: str | Path) -> RunFiles:
40
+ """The files ``run_dir`` was set up with, as its config page shows them."""
41
+ run_dir = Path(run_dir)
42
+ manifest = read_json(run_dir / "snapshot.json") or {}
43
+ versions = manifest.get("versions") or []
44
+ texts = {int(p.stem.removeprefix("open-")): p.read_text() for p in (run_dir / "config").glob("open-*.yaml")}
45
+ configs, before = [], None
46
+ for opening, text in sorted(texts.items()):
47
+ opened_at = versions[opening - 1].get("opened_at") if opening <= len(versions) else None
48
+ configs.append(ConfigCopy(opening, opened_at, text, None if before is None else line_changes(before, text)))
49
+ before = text
50
+ snapshot = tuple(SnapshotFile(name, (run_dir / "snapshot" / name).read_text()) for name in manifest.get("files", {}))
51
+ return RunFiles(tuple(sorted(configs, key=lambda c: c.opening, reverse=True)), snapshot)
@@ -0,0 +1,110 @@
1
+ """The diagnosis: at most three findings in a run's trials, each with what to do about it.
2
+
3
+ The rules and their thresholds are fixed here. Each finding counts trials,
4
+ not attempts: a trial is counted once whichever of its versions met the
5
+ problem.
6
+ """
7
+
8
+ from dataclasses import dataclass
9
+
10
+ from factor_agent.web._ledger import THRESHOLD, Trial, neighbour
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class Finding:
15
+ """One finding of a run's diagnosis: a thing seen in its trials and what to do about it.
16
+
17
+ ``rule`` names the rule that found it: ``"unreadable_replies"``,
18
+ ``"known_collisions"`` (one of the user's known factors),
19
+ ``"accepted_collisions"`` (factors the run accepted), ``"coverage_refusals"``,
20
+ ``"graph_build_failures"`` or ``"below_threshold"``. ``trials`` counts the
21
+ trials it concerns. ``seen`` and ``do`` are sentences for the page.
22
+ """
23
+
24
+ rule: str
25
+ trials: int
26
+ seen: str
27
+ do: str
28
+
29
+
30
+ #: Findings shown at most.
31
+ _FINDINGS = 3
32
+
33
+ #: The share of trials above which unreadable replies, coverage refusals and graph build failures are findings.
34
+ _UNREADABLE_SHARE, _COVERAGE_SHARE, _BUILD_SHARE = 0.20, 0.15, 0.15
35
+
36
+ #: The number of trials above which collisions with one known factor, or with accepted ones, are a finding.
37
+ _COLLIDING_TRIALS = 5
38
+
39
+
40
+ def _refusals(trial: Trial) -> list[tuple[str, str]]:
41
+ """Every (stage, reason) the trial's versions were refused with, its final refusal among them."""
42
+ found = [(attempt.get("stage", ""), r) for attempt in trial.attempts for r in attempt.get("reasons") or ()]
43
+ return found + [(trial.stage, r) for r in trial.reasons]
44
+
45
+
46
+ def _share(count: int, total: int) -> str:
47
+ """``count`` of ``total`` trials, with the percentage."""
48
+ return f"{count} of {total} trials ({count / total:.0%})"
49
+
50
+
51
+ def diagnosis(trials: tuple[Trial, ...]) -> tuple[Finding, ...]:
52
+ """The findings of the rules that hold, the most trials first, at most three.
53
+
54
+ Collisions with a reference factor are no finding: the reference pool is
55
+ there to be compared with, while a known factor the run keeps rewriting
56
+ is one the user may not mean to name, and an accepted one shows the
57
+ novelty checks at work.
58
+ """
59
+ n = len(trials)
60
+ if not n:
61
+ return ()
62
+ findings = []
63
+ unreadable = sum(any(stage == "parse" for stage, _ in _refusals(t)) for t in trials)
64
+ if unreadable > _UNREADABLE_SHARE * n:
65
+ findings.append(Finding(
66
+ "unreadable_replies", unreadable,
67
+ f"Unreadable LLM replies: {_share(unreadable, n)}.",
68
+ "Change the model or raise its output limit."))
69
+ known, accepted = {}, {}
70
+ for trial in trials:
71
+ for collision in {c.label: c for c in trial.collisions}.values():
72
+ if collision.known:
73
+ known.setdefault(collision.label, []).append(trial)
74
+ elif collision.accepted_round is not None:
75
+ accepted.setdefault(collision.label, []).append(trial)
76
+ if known:
77
+ label, colliding = max(known.items(), key=lambda item: len(item[1]))
78
+ if len(colliding) > _COLLIDING_TRIALS:
79
+ findings.append(Finding(
80
+ "known_collisions", len(colliding),
81
+ f"Collided with your known factor {label}: {_share(len(colliding), n)}.",
82
+ "Steer the research directions away from it, or check your known factors."))
83
+ merged = {id(t) for colliding in accepted.values() for t in colliding}
84
+ if len(merged) > _COLLIDING_TRIALS:
85
+ names = ", ".join(f"{neighbour(label, None).name} ({len(colliding)})"
86
+ for label, colliding in sorted(accepted.items(), key=lambda item: -len(item[1])))
87
+ findings.append(Finding(
88
+ "accepted_collisions", len(merged),
89
+ f"Collided with factors the run accepted: {_share(len(merged), n)} ({names}).",
90
+ "Expected: the novelty checks at work. If it persists, change the research directions."))
91
+ coverage = sum(any(stage == "admission" and r.startswith("coverage ") for stage, r in _refusals(t)) for t in trials)
92
+ if coverage > _COVERAGE_SHARE * n:
93
+ findings.append(Finding(
94
+ "coverage_refusals", coverage,
95
+ f"Too few members covered: {_share(coverage, n)}.",
96
+ "Check your data's fields and dates."))
97
+ builds = sum(any(r.startswith(("graph build failed", "graph build crashed")) for _, r in _refusals(t)) for t in trials)
98
+ if builds > _BUILD_SHARE * n:
99
+ findings.append(Finding(
100
+ "graph_build_failures", builds,
101
+ f"Code failed to build: {_share(builds, n)}.",
102
+ "Check the operator file, or try another model."))
103
+ evaluated = [t for t in trials if t.metrics is not None]
104
+ below = sum(any(THRESHOLD.search(r) for r in t.reasons) for t in evaluated)
105
+ if not any(t.status == "accepted" for t in trials) and below > len(evaluated) / 2:
106
+ findings.append(Finding(
107
+ "below_threshold", below,
108
+ f"Nothing accepted: {below} of {len(evaluated)} evaluated trials below the t threshold.",
109
+ "The statistical bar, not an error: run more rounds or change the research directions."))
110
+ return tuple(sorted(findings, key=lambda f: -f.trials)[:_FINDINGS])
@@ -0,0 +1,78 @@
1
+ """The run directory's files as every part of the reader opens them, and its events replayed once."""
2
+
3
+ import difflib
4
+ import json
5
+ from dataclasses import dataclass
6
+ from datetime import datetime, timezone
7
+ from pathlib import Path
8
+
9
+ from factor_agent import events
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class LineChange:
14
+ """One line of a text compared with the text before it.
15
+
16
+ ``change`` is ``"kept"``, ``"added"`` (not in the text before) or
17
+ ``"removed"`` (in the text before, not in this one).
18
+ """
19
+
20
+ change: str
21
+ line: str
22
+
23
+
24
+ def line_changes(before: str, after: str) -> tuple[LineChange, ...]:
25
+ """The lines of ``before`` and ``after`` in order, each marked kept, added or removed."""
26
+ old, new = before.splitlines(), after.splitlines()
27
+ lines = []
28
+ for tag, i1, i2, j1, j2 in difflib.SequenceMatcher(a=old, b=new, autojunk=False).get_opcodes():
29
+ if tag == "equal":
30
+ lines += [LineChange("kept", line) for line in new[j1:j2]]
31
+ continue
32
+ lines += [LineChange("removed", line) for line in old[i1:i2]]
33
+ lines += [LineChange("added", line) for line in new[j1:j2]]
34
+ return tuple(lines)
35
+
36
+
37
+ def loads(text: str):
38
+ """JSON text read with NaN as ``None``: the ledger and the Acceptance reports write NaN where a value is undefined."""
39
+ return json.loads(text, parse_constant=lambda _: None)
40
+
41
+
42
+ def read_json(path: Path):
43
+ """The JSON file at ``path``, ``None`` when there is none."""
44
+ try:
45
+ return loads(path.read_text())
46
+ except FileNotFoundError:
47
+ return None
48
+
49
+
50
+ def utc(seconds: float) -> datetime:
51
+ """The UTC time of a file time."""
52
+ return datetime.fromtimestamp(seconds, timezone.utc)
53
+
54
+
55
+ def call_files(run_dir: Path) -> list[Path]:
56
+ """The run's call files, in no order."""
57
+ return list((run_dir / "llm_log").glob("*.json"))
58
+
59
+
60
+ def written(run_dir: Path) -> list[Path]:
61
+ """The run's files that change while it runs."""
62
+ files = [run_dir / name for name in ("snapshot.json", "state.json", "ledger.jsonl", events.FILE)]
63
+ return [p for p in files if p.exists()] + call_files(run_dir)
64
+
65
+
66
+ class Replayed:
67
+ """A run's events, and those since its latest opening (``current``), the opening first."""
68
+
69
+ def __init__(self, replayed: list[events.Event]):
70
+ self.all = replayed
71
+ openings = [i for i, event in enumerate(replayed) if isinstance(event, events.Start)]
72
+ self.current = replayed[openings[-1]:] if openings else []
73
+ self.opened: events.Start | None = self.current[0] if self.current else None
74
+
75
+ @classmethod
76
+ def read(cls, run_dir: Path) -> "Replayed":
77
+ """The events of the run in ``run_dir``; none when it has no events file."""
78
+ return cls(events.read(run_dir / events.FILE))