Source code for factor_agent.events

"""The events file: what a campaign is doing, appended as it happens, for anyone watching the run.

A campaign appends one event per line to ``events.jsonl`` in its run
directory: a JSON object with the file's schema version (``"schema"``), the
event's type (``"type"``), the time it happened (``"time"``, UTC, ISO 8601)
and the event's fields. The file is append-only, and the campaign never
reads it: it takes no part in a resume, a replay or the run's snapshot, so
deleting it loses the record of what happened but changes no result. A
resume that drops a round a crash left half written says so in its
``resume`` event instead of removing that round's events.

The event types, defined here once and read back by ``read``:

- ``start`` and ``resume`` (``Start``, ``Resume``): the campaign's process
  id and host, the code versions, the rounds already written, the stop
  conditions (round and trial limits, patience) and the admission limits;
  a resume also names the round it dropped;
- ``plan`` (``Plan``): a round's research directions in slot order, its
  quota, the plan's issues and its Picked parents;
- ``call_start`` and ``call_end`` (``CallStart``, ``CallEnd``): every LLM
  call, with its role, the proposal group and the slots it concerns; its
  end names the call file it wrote, how long it took, its tokens as far
  as the provider's reply reports them, and whether the reply was cut off
  at the output limit or empty;
- ``gate`` (``GateOutcome``): every version of a slot's candidate the
  parser and the Gate checked, passed or refused with the reasons and hints;
- ``review`` (``ReviewVerdict``): the reviewer's verdict on a slot, with its reason;
- ``evaluation`` (``EvaluationStart``): the Evaluator starts on the round's
  candidates the reviewer found consistent;
- ``admission`` (``AdmissionOutcome``): a slot's outcome after evaluation,
  with the t threshold in force and the metrics;
- ``round_end`` (``RoundEnd``): a round's trials are written;
- ``stop`` (``Stopped``): the run stopped, as the state file records it.

A slot is a candidate's place in its round, from 0, in the order of the
round's research directions (and of the round's lines in the ledger). A
value the campaign holds as NaN or infinite is written as ``null``.
"""

import itertools
import json
import math
import threading
from dataclasses import asdict, dataclass, field, fields
from datetime import datetime, timezone
from pathlib import Path
from typing import Literal

from factor_agent.llm._raw import raw_facts

#: The events file's name in a run directory.
FILE = "events.jsonl"

#: The version of the events file's format: it changes when an event loses or changes a field.
SCHEMA = 1


def _now() -> datetime:
    return datetime.now(timezone.utc)


[docs] @dataclass(frozen=True, kw_only=True) class Event: """What every event has: the time it happened (UTC).""" #: The event's type in the file; each event class sets its own. type = "" time: datetime = field(default_factory=_now)
[docs] @dataclass(frozen=True, kw_only=True) class Start(Event): """A campaign opened its run directory, with what it runs on and when it stops. ``pid`` and ``host`` are the campaign's process id and host name; ``versions`` the code versions the run's snapshot records for this opening; ``rounds`` the rounds already written. ``max_rounds``, ``max_trials`` and ``patience`` are the stop conditions, ``candidates`` the slots of a round; ``t_threshold``, ``trial_exponent``, ``max_correlation``, ``min_coverage`` and ``min_monotonicity`` the admission settings (``trial_exponent`` is 1 in a record written before it was). """ type = "start" pid: int host: str versions: dict rounds: int candidates: int max_rounds: int max_trials: int patience: int t_threshold: float max_correlation: float min_coverage: float min_monotonicity: float trial_exponent: float = 1.0
[docs] @dataclass(frozen=True, kw_only=True) class Resume(Start): """A campaign opened a run directory it had opened before; ``dropped`` is the round it drops, if any. The events of the dropped round stay in the file before this one; the round runs again under the same number after it. """ type = "resume" dropped: int | None = None
[docs] @dataclass(frozen=True, kw_only=True) class Plan(Event): """Round ``round``'s research directions, in slot order, the quota by mechanism type and the plan's issues. ``picked`` are the round's Picked parents in pick order, each with the ``trial``, the ``mechanism_type`` of the slot it was given, its ``quality``, ``fertility`` and ``novelty`` after propagation and its Pareto ``front`` (``_trial_graph``); empty without ``round.picked_parents``. """ type = "plan" round: int directions: list[dict] quota: dict[str, int] issues: list[str] picked: list[dict] = field(default_factory=list)
[docs] @dataclass(frozen=True, kw_only=True) class CallStart(Event): """An LLM call was sent. ``call`` tells the calls of one opening of the run apart (``CallEnd`` names the same). ``group`` is the proposal group and ``slots`` the slots the call concerns; both are empty for the planner and the distiller, whose calls concern the whole round. """ type = "call_start" call: int round: int role: str model: str group: int | None = None slots: list[int] = field(default_factory=list)
[docs] @dataclass(frozen=True, kw_only=True) class CallEnd(CallStart): """An LLM call came back, or failed. ``file`` is the call file's name in ``llm_log/``, ``None`` when the call failed (``error`` says how) and wrote none. ``seconds`` is how long it took. The token counts are the provider's, ``None`` where its reply does not report them: ``cache_hits`` are input tokens read from the provider's cache, ``reasoning`` output tokens spent reasoning. ``truncated`` is true when the reply was cut off at the output limit, ``None`` when the provider's reply does not say; ``empty`` when the reply holds no text. """ type = "call_end" file: str | None = None seconds: float = 0.0 input_tokens: int | None = None output_tokens: int | None = None cache_hits: int | None = None reasoning: int | None = None truncated: bool | None = None empty: bool = False error: str | None = None
#: Which version of a slot's candidate the Gate checks: the first, one fixed after a refusal, or one rewritten. GateStep = Literal["proposed", "fixed", "rewritten"]
[docs] @dataclass(frozen=True, kw_only=True) class GateOutcome(Event): """One version of a slot's candidate checked by the parser and the Gate. ``step`` is ``"proposed"`` (the first version), ``"fixed"`` (a version sent back by the parser or the Gate) or ``"rewritten"`` (a version the reviewer sent back). ``item`` is the version as the proposer wrote it. ``stage`` is ``"parse"`` or ``"gate"`` for a refusal, with its ``reasons`` and ``hints``, and ``None`` when it ``passed``. """ type = "gate" round: int slot: int step: GateStep item: object passed: bool stage: str | None = None reasons: list[str] = field(default_factory=list) hints: list[str] = field(default_factory=list)
[docs] @dataclass(frozen=True, kw_only=True) class ReviewVerdict(Event): """The reviewer's verdict on a slot's candidate, with its reason; ``rewrite`` when it judged a rewrite.""" type = "review" round: int slot: int consistent: bool reason: str rewrite: bool = False
[docs] @dataclass(frozen=True, kw_only=True) class EvaluationStart(Event): """The Evaluator starts on round ``round``'s ``slots``: the candidates the reviewer found consistent.""" type = "evaluation" round: int slots: list[int]
[docs] @dataclass(frozen=True, kw_only=True) class AdmissionOutcome(Event): """A slot's outcome after evaluation. ``status`` is ``"accepted"`` or ``"rejected"`` (``stage`` ``"admission"``), or ``"failed"`` (``stage`` ``"evaluate"``) when its graph did not compile. ``threshold`` is the partial-IC t it had to reach; ``metrics`` are the Evaluator's (``None`` for a failure). """ type = "admission" round: int slot: int status: str stage: str reasons: list[str] threshold: float | None metrics: dict | None
[docs] @dataclass(frozen=True, kw_only=True) class RoundEnd(Event): """Round ``round``'s ``trials`` are written, ``accepted`` of them accepted.""" type = "round_end" round: int trials: int accepted: int
[docs] @dataclass(frozen=True, kw_only=True) class Stopped(Event): """The run stopped: ``by`` the stop condition, ``reason`` its sentence, as the state file records them.""" type = "stop" by: str reason: str
#: Every event class by its type. TYPES: dict[str, type[Event]] = { cls.type: cls for cls in (Start, Resume, Plan, CallStart, CallEnd, GateOutcome, ReviewVerdict, EvaluationStart, AdmissionOutcome, RoundEnd, Stopped) }
[docs] class Events: """The events file of one run directory, appended to by a campaign; safe to use from several threads.""" def __init__(self, path: str | Path): self.path = Path(path) self._writing = threading.Lock() self._calls = itertools.count(1)
[docs] def write(self, event: Event) -> None: """Append ``event`` as one line.""" line = {"schema": SCHEMA, "type": event.type, **_plain(asdict(event))} text = json.dumps(line, ensure_ascii=False, allow_nan=False) + "\n" with self._writing: self.path.parent.mkdir(parents=True, exist_ok=True) with self.path.open("a") as out: out.write(text)
[docs] def next_call(self) -> int: """A number for the next call, unique in this opening of the run.""" with self._writing: return next(self._calls)
[docs] def read(path: str | Path) -> list[Event]: """The events in the file at ``path``, in order; an empty list when there is none. A line a crash cut short, a line of a later schema and an event of an unknown type are skipped; a field this version does not know is ignored. """ try: lines = Path(path).read_text().splitlines() except FileNotFoundError: return [] events = [] for line in lines: try: value = json.loads(line) except json.JSONDecodeError: continue cls = TYPES.get(value.get("type")) if isinstance(value, dict) else None if cls is None or value.get("schema") != SCHEMA: continue known = {f.name for f in fields(cls)} try: events.append(cls(**{k: v for k, v in value.items() if k in known and k != "time"}, time=datetime.fromisoformat(value["time"]))) except (TypeError, KeyError, ValueError): continue return events
[docs] def reply_facts(reply) -> dict: """What a call's ``CallEnd`` says of its reply: tokens, truncation and emptiness, as far as the reply reports them. The input and output tokens are the reply's ``usage``; the cache hits, the reasoning tokens and the stop reason come from the provider's raw reply: an Anthropic message, an OpenAI-compatible chat completion or the Claude Code CLI's result. """ usage, facts = reply.usage or {}, raw_facts(reply.raw) return { "input_tokens": usage.get("input_tokens"), "output_tokens": usage.get("output_tokens"), "cache_hits": facts.cache_hits, "reasoning": facts.reasoning_tokens, "truncated": facts.truncated, "empty": not reply.text.strip(), }
def _plain(value): """``value`` as strict JSON holds it: NaN and infinities as ``None``, times as ISO 8601 text.""" if isinstance(value, float): return value if math.isfinite(value) else None if isinstance(value, datetime): return value.isoformat() if isinstance(value, dict): return {str(k): _plain(v) for k, v in value.items()} if isinstance(value, (list, tuple)): return [_plain(v) for v in value] return value