"""The trials of a run's written rounds, the collisions their refusals name, and the limits admission judged them by."""
import json
import re
from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path
from typing import TYPE_CHECKING
import yaml
from factor_agent import events
from factor_agent.web._files import Replayed, loads, read_json
if TYPE_CHECKING:
from factor_agent.web._plans import TrialLineage
[docs]
@dataclass(frozen=True)
class Collision:
"""A trial's refusal for being too like an existing factor: a reference, known or accepted one.
``label`` names the factor as the Gate and the Evaluator do; ``name`` is
how a page names it, as ``Neighbour.name``. ``kind`` is ``"code"`` (the
Gate found a rewrite, the same graph with other parameters, or a too
large shared subtree) or ``"ranking"`` (admission found a correlation
above the limit). ``accepted_round`` is the round that accepted the
factor, ``None`` for one of the user's own factors; ``known`` is true
for a known factor, false for a reference or accepted one.
"""
label: str
name: str
kind: str
accepted_round: int | None
known: bool
[docs]
@dataclass(frozen=True)
class Trial:
"""One trial of a written round, as the ledger holds it, with NaN read as ``None``.
``slot`` is the trial's place in its round, from 0, in the order of the
round's research directions. ``status``, ``stage``, ``reasons``,
``candidate``, ``direction``, ``attempts``, ``stats`` and ``metrics`` are
the trial record's (``factor_agent.ledger.TrialRecord``); ``metrics`` is
``None`` for a trial the Evaluator never scored. ``collisions`` holds
every collision of the trial's versions, each factor and kind once.
``intraday`` is true when the graph the Gate built holds a session
aggregation, false for a daily graph and a trial the Gate built none for.
``lineage`` is its Lineage (``_plans``), ``None`` until the view adds it;
``picked_in`` the rounds whose plans picked it as a Picked parent, the
round in progress included, the earliest first; ``parent_picked`` is
true when the code picked its parent, false when the planner chose it
or it has none.
"""
round: int
slot: int
status: str
stage: str
reasons: tuple[str, ...]
candidate: dict
direction: dict | None
attempts: tuple[dict, ...]
stats: dict | None
metrics: dict | None
collisions: tuple[Collision, ...] = ()
intraday: bool = False
lineage: "TrialLineage | None" = None
picked_in: tuple[int, ...] = ()
parent_picked: bool = False
[docs]
@dataclass(frozen=True)
class Neighbour:
"""The factor closest to an accepted one in ranking, and their correlation.
``label`` names it as the Evaluator does. ``name`` is how a page names
it: a factor accepted earlier in the run as ``"round N: "`` and the first
words of its description, any other by its label. ``accepted_round`` is
N for a factor accepted in the run, ``None`` for a reference or known
factor. ``correlation`` is the raw cross-sectional correlation, which
admission compares in absolute value with its limit.
"""
label: str
name: str
accepted_round: int | None
correlation: float | None
def _records(ledger: Path, rounds: int):
"""The ledger's records of the first ``rounds`` rounds; later rounds and a line a crash cut short are skipped."""
try:
lines = ledger.read_text().splitlines()
except FileNotFoundError:
return
for line in lines:
try:
record = loads(line)
except json.JSONDecodeError:
continue
if record["round"] <= rounds:
yield record
def statuses(ledger: Path, rounds: int) -> list[str]:
"""The status of every trial of the first ``rounds`` rounds, in ledger order: what a run's summary counts."""
return [record["status"] for record in _records(ledger, rounds)]
def read_trials(ledger: Path, rounds: int, aggregations: frozenset[str] = frozenset()) -> tuple[Trial, ...]:
"""The trials of the first ``rounds`` rounds, each given its slot in its round.
``aggregations`` are the names the run's graphs record its session
aggregations under (``aggregations``): a trial whose graph holds one is intraday.
"""
trials, slots = [], {}
for record in _records(ledger, rounds):
slot = slots[record["round"]] = slots.get(record["round"], -1) + 1
trials.append(Trial(
round=record["round"], slot=slot, status=record["status"], stage=record["stage"],
reasons=tuple(record["reasons"]), candidate=record["candidate"], direction=record.get("direction"),
attempts=tuple(record.get("attempts") or ()), stats=record.get("stats"), metrics=record.get("metrics"),
collisions=_trial_collisions(record),
intraday=any(node[0] in aggregations for node in record.get("graph") or ()),
))
return tuple(trials)
def aggregations(run_dir: Path) -> frozenset[str]:
"""The names the run's operator file registers its session aggregations under; none for a daily run.
Only a run whose snapshot records an intraday dataset has an intraday
family, and only it may use an aggregation. An operator is one when its
class, from the modules an aggregation may come from, is a
``SessionAggregation``; a class of another module is not imported.
"""
manifest = read_json(run_dir / "snapshot.json") or {}
if "intraday" not in (manifest.get("settings") or {}):
return frozenset()
operators = next((name for name in manifest.get("files") or {} if name.startswith("operators/")), None)
try:
return _aggregations((run_dir / "snapshot" / operators).read_text()) if operators else frozenset()
except OSError:
return frozenset()
@lru_cache(maxsize=16)
def _aggregations(text: str) -> frozenset[str]:
"""The aggregations of the operator file ``text``, by their names; see ``aggregations``."""
# Imported here, so a daily run's view never imports the operator classes.
from factor_agent.aggregations import SessionAggregation
from factor_agent.gate.vocabulary import FRAMEWORK_MODULES, resolve
try:
entries = (yaml.safe_load(text) or {}).get("operators") or []
except (yaml.YAMLError, AttributeError):
return frozenset()
found = set()
for entry in entries:
path = entry.get("class") if isinstance(entry, dict) else None
if not isinstance(path, str) or not any(path.startswith(root + ".") for root in FRAMEWORK_MODULES):
continue
try:
cls = resolve(path, FRAMEWORK_MODULES)
except (ImportError, AttributeError):
continue
if isinstance(cls, type) and issubclass(cls, SessionAggregation):
found.add(entry.get("name"))
return frozenset(found)
#: The partial-IC t threshold an admission refusal quotes.
THRESHOLD = re.compile(r"is below the threshold ([\d.]+) at \d+ evaluated trial")
#: The correlation limit an admission refusal quotes.
_LIMIT = re.compile(r"^cross-sectional correlation .* is above ([\d.]+) in absolute value$", re.DOTALL)
def correlation_limit(trials: tuple[Trial, ...], replayed: Replayed) -> float | None:
"""The correlation limit the run was last opened with, else the latest refusal quoting it, else ``None``."""
if replayed.opened is not None:
return replayed.opened.max_correlation
quoted = [found.group(1) for trial in trials for reason in trial.reasons if (found := _LIMIT.match(reason))]
return float(quoted[-1]) if quoted else None
def thresholds(trials: tuple[Trial, ...], replayed: Replayed) -> dict[int, tuple[float | None, str | None]]:
"""Each round's threshold and where it comes from: ``"admission"``, ``"refusals"`` or ``"carried_over"``.
A round's admission events give it; without them its refusals quote it;
when neither does it is the round before's, carried over. The admission
events of a round a resume dropped come before those of the round run
again under the same number, so the later ones count. A round before
any threshold has ``(None, None)``.
"""
given = {}
for trial in trials:
for reason in trial.reasons:
if found := THRESHOLD.search(reason):
given[trial.round] = (float(found.group(1)), "refusals")
for event in replayed.all:
if isinstance(event, events.AdmissionOutcome) and event.threshold is not None:
given[event.round] = (event.threshold, "admission")
found, last = {}, None
for number in sorted({trial.round for trial in trials}):
if number in given:
found[number] = last = given[number]
else:
found[number] = (None, None) if last is None else (last[0], "carried_over")
return found
#: An accepted factor's label: its description and the round that accepted it.
_ACCEPTED = re.compile(r"^(.*) \(accepted in round (\d+)\)$", re.DOTALL)
#: How many words of an earlier accepted factor's description name it.
_FIRST_WORDS = 6
def neighbour(label: str | None, correlation: float | None) -> Neighbour | None:
"""The nearest factor the Evaluator recorded, ``None`` when there was none to compare with."""
if not label:
return None
if accepted := _ACCEPTED.match(label):
words = accepted.group(1).split()
first = " ".join(words[:_FIRST_WORDS]) + ("…" if len(words) > _FIRST_WORDS else "")
number = int(accepted.group(2))
return Neighbour(label=label, name=f"round {number}: {first}", accepted_round=number, correlation=correlation)
return Neighbour(label=label, name=label, accepted_round=None, correlation=correlation)
#: The refusals that name the factors a candidate collided with, by kind; group 1 holds the names.
_COLLISIONS = (
("code", re.compile(r"^the factor rewrites (.+?(?:\)| more)): ", re.DOTALL)),
("code", re.compile(r"^the factor is (.+?) with other windows or parameter values only; ", re.DOTALL)),
("code", re.compile(r"^the factor shares a \d+-node subtree with (.+?), more than the limit of \d+: ", re.DOTALL)),
("ranking", re.compile(
r"^cross-sectional correlation [-+]?[\d.]+ with (.+?)(?:, sampled on [^,]*,)? is above [\d.]+ in absolute value$",
re.DOTALL)),
)
#: One label in the Gate's list of them: a reference, known or accepted factor's label ends in its parentheses.
_LABEL = re.compile(r"(.+?) \((accepted in round \d+|[\w.]+(?:, a known factor)?)\)(?:, |(?: and \d+ more)?$)", re.DOTALL)
def _collisions(reason: str) -> list[Collision]:
"""The collisions a refusal names, none for any other refusal."""
for kind, pattern in _COLLISIONS:
if found := pattern.match(reason):
names = found.group(1)
labels = [names] if kind == "ranking" else [f"{m[1]} ({m[2]})" for m in _LABEL.finditer(names)]
return [_collision(label, kind) for label in labels]
return []
def _collision(label: str, kind: str) -> Collision:
"""The collision of ``kind`` with the factor ``label`` names."""
named = neighbour(label, None)
return Collision(label=label, name=named.name, kind=kind, accepted_round=named.accepted_round,
known=label.endswith(", a known factor)"))
def _trial_collisions(record: dict) -> tuple[Collision, ...]:
"""Every collision of a trial's versions, each factor and kind once."""
reasons = [r for attempt in record.get("attempts") or () for r in attempt.get("reasons") or ()]
found = {}
for reason in reasons + list(record["reasons"]):
for collision in _collisions(reason):
found.setdefault((collision.label, collision.kind), collision)
return tuple(found.values())
def in_direction(metrics: dict) -> tuple[int, dict[str, float | None]]:
"""A scored trial's direction, +1 or -1 by the sign of its training IC, and its ICs and t in that direction."""
sign = 1 if (metrics.get("ic_mean") or 0) > 0 else -1
return sign, {key: None if metrics.get(key) is None else sign * metrics[key]
for key in ("ic_mean", "ic_t", "partial_ic_mean", "partial_ic_t")}
def _ranks(values: list[float]) -> list[float]:
"""Each value's rank from 1, ties given their mean rank."""
order = sorted(range(len(values)), key=values.__getitem__)
ranks = [0.0] * len(values)
start = 0
while start < len(order):
end = start
while end + 1 < len(order) and values[order[end + 1]] == values[order[start]]:
end += 1
for i in order[start:end + 1]:
ranks[i] = (start + end) / 2 + 1
start = end + 1
return ranks
def monotonicity(deciles: tuple[float | None, ...], sign: int) -> float | None:
"""The Spearman correlation of the decile number with the decile returns, signed by ``sign``, as admission's."""
if len(deciles) < 2 or any(r is None for r in deciles) or len(set(deciles)) == 1:
return None
ranks, n = _ranks(list(deciles)), len(deciles)
mean = (n + 1) / 2
spread = sum((r - mean) ** 2 for r in ranks) * sum((i + 1 - mean) ** 2 for i in range(n))
return sign * sum((r - mean) * (i + 1 - mean) for i, r in enumerate(ranks)) / spread ** 0.5