Skip to content

Events and audit log

A session announces what it does through events, and records the decisions behind a campaign in an audit log. Events are for your code; the audit log is for people to read afterwards, as a methods section or a record of what was run and why.

Events

An event is a name and a dictionary. Subscribe with session.on(name, callback), and the callback runs each time the session emits that name, with the dictionary as its one argument. This listener counts what a short campaign does.

from alchemist_core import OptimizationSession

session = OptimizationSession()
heard = []

for name in ("variable_added", "initial_design_generated", "experiment_added",
             "training_completed", "acquisition_completed"):
    session.on(name, lambda data, name=name: heard.append((name, sorted(data))))

session.add_variable("temperature", "real", bounds=(200.0, 300.0))
session.add_variable("pressure", "real", bounds=(20.0, 80.0))
for p in session.generate_initial_design("lhs", n_points=6, random_seed=1):
    y = -((p["temperature"] - 250) / 50) ** 2 - ((p["pressure"] - 50) / 30) ** 2
    session.add_experiment(p, output=y, reason="Initial Design")
session.train_model(backend="sklearn")
suggestion = session.suggest_next(strategy="EI", goal="maximize")

print(len(heard))
print(heard[-1])

session.on is shorthand for session.events.on; both register on the session's EventEmitter, which also has once, off, remove_all_listeners, listener_count and event_names. Adding the same callback twice for one name has no effect.

Listeners run synchronously, on the thread that made the call, in the order they were added. A listener that raises is logged and skipped, and the session call carries on. A name nothing emits is accepted without complaint, so a misspelt name never fires; check it against the table below.

The events a session emits

Event When Payload keys
variable_added add_variable name, type
derived_variable_added add_derived_variable name, input_cols
search_space_loaded load_search_space filepath
data_loaded load_data n_experiments, filepath
experiment_added add_experiment, and each recorded queue item inputs, output
experiment_staged, staged_experiments_cleared add_staged_experiment; clear_staged_experiments and move_staged_to_experiments when they removed any inputs; count
queue_item_updated An item is staged, started, completed or failed item_id, status, reason, output, error
queue_updated The queue changed in any way, including delete and purge n_pending, n_running, n_done, n_failed
initial_design_generated generate_initial_design, generate_split_plot_design, generate_optimal_design method, n_points; an optimal design adds criterion and D_eff
training_started train_model begins backend, kernel
training_completed train_model succeeds backend, kernel, hyperparameters, metrics, success, n_objectives
acquisition_started suggest_next begins strategy, goal
acquisition_completed suggest_next returns suggestion, the first suggested point only
data_locked, model_locked, acquisition_locked lock_data, lock_model, lock_acquisition entry, the audit entry as a dictionary
session_saved save_session filepath
session_loaded load_session filepath
metadata_updated update_metadata The metadata fields: session_id, name, created_at, last_modified, description, author, tags
model_retrained, model_retrain_failed The retrain step of load_session backend, or error

complete_experiment adds a row, so it emits experiment_added as well as the queue events. A failed training emits training_started and no training_completed. Nothing emits model_trained or acquisition_generated.

With the instance form, session.load_session(path), the session you called it on emits model_retrained or model_retrain_failed (when the file carries a model configuration and retrain_on_load is on) and then session_loaded. The class form, OptimizationSession.load_session(path), returns a new session, so there is nothing to subscribe to beforehand; check session.model after it returns.

The REST server runs sessions in the same process, so these events fire there too, but nothing forwards them; the server's WebSocket sends messages of its own, such as experiments_updated. To give several sessions one emitter, pass event_emitter= to OptimizationSession; the work queue always uses its own session's emitter.

The audit log

The audit log holds the decisions a campaign was built on. Exploring writes nothing to it; an entry appears when something is committed, which keeps the log short enough to read. Nothing edits or removes an entry once it is written, except AuditLog.clear(), which starts a new campaign in the same session.

Each entry has a timestamp, an entry_type, a parameters dictionary that differs by type, free-text notes, and a hash: the first 16 hexadecimal characters of the SHA-256 of parameters, serialized as JSON with sorted keys.

Entry types

entry_type Written by parameters
data_locked lock_data, and by itself when you generate an initial, optimal or split-plot design n_experiments and data_hash; a design adds initial_design_method and initial_design_n_points, and an optimal design criterion and D_eff
model_locked lock_model backend, kernel, hyperparameters, cv_metrics when available, and iteration
acquisition_locked lock_acquisition strategy, parameters, suggestions and iteration
experiment_completed complete_experiment, and the REST routes that record a result, manual ones included The provenance record: suggested and actual conditions, their difference and the result
config_changed The REST routes that train a model and ask for a suggestion, which the web app uses component (model or acquisition), old, new and a running count as iteration; old is always empty
objective_label_changed set_objective_metadata, when a label or unit actually changed old and new
control_requested, control_reported The control channel, when the value changed; heartbeats are not logged requested or reported, by, and detail for a report
Any other string AuditLog.log_event, or POST .../audit/event Whatever you pass

lock_acquisition adds one to the session's iteration counter and records the new number. lock_model records the number the next acquisition will take, so a model locked just before an acquisition shares its iteration. suggest_next locks nothing in Python; the REST route that suggests locks the acquisition itself, so a web session has an entry per suggestion.

Lock a decision and read it back

def announce(data):
    entry = data["entry"]
    print("locked", entry["entry_type"], entry["hash"])

session.on("acquisition_locked", announce)

session.lock_data(notes="Initial design and first results")
session.lock_model(notes="Default Matern kernel")
session.lock_acquisition(
    strategy="EI",
    parameters={"goal": "maximize"},
    suggestions=suggestion.to_dict("records"),
    notes="Round 1",
)

log = session.audit_log
print(len(log), [entry.entry_type for entry in log.get_entries()])
latest = log.get_latest("acquisition_locked")
print(latest.parameters["iteration"], latest.parameters["suggestions"][0].keys())

get_entries(entry_type=None) returns AuditEntry objects, all or one type, and get_latest(entry_type) the most recent or None. session.get_audit_log() returns the whole log as a dictionary, which is what the session file stores. lock_model raises a ValueError without a trained model.

The timestamp and notes are not hashed. The hash shows that an entry has not changed since it was written, and you can recompute it by hashing the entry's parameters the same way. It is a checksum and not a signature: anyone who can edit the session file can edit an entry and its hash together.

Add your own entries

log_event(entry_type, parameters, notes="") writes an entry of any type, for a fact the campaign depends on that ALchemist does not know, such as a catalyst batch. The type is any string, up to 64 characters over REST, and ALchemist never interprets it.

log.log_event(
    "sample_prepared",
    {"batch": "B-17", "furnace_ramp_C_per_min": 5},
    notes="From the lab notebook",
)
print(log.get_latest("sample_prepared").parameters)

The entry is saved with the session and returned by GET .../audit. Over REST the same call is:

POST /api/v1/sessions/{session_id}/audit/event
Content-Type: application/json

{"entry_type": "sample_prepared", "parameters": {"batch": "B-17"}, "notes": "From the lab notebook"}

Export the audit log as Markdown

session.export_audit_markdown() returns the session's metadata and the log together as one Markdown document for a methods section; AuditLog.to_markdown(session_metadata=None) underneath leaves the metadata part out unless you pass it. Write the result to a file next to the session file.

with open("campaign_audit_log.md", "w") as handle:
    handle.write(session.export_audit_markdown())
print(session.export_audit_markdown().splitlines()[0])

The parts of the document are described under Sessions and shown in File formats.

The iteration blocks are built from the data_locked, model_locked and acquisition_locked entries. A data_locked entry with no design metadata has no block of its own; the experiment table shows the data it locked. Entries of other types, your own included, are in the session file and GET .../audit and print no content in the Markdown.

Known issue

W23: the search-space part lists a discrete variable as (categorical): {}, with its values missing; the values are correct in the session file and the data table. Also, every entry whose parameters carry an iteration, config_changed (a running count) and experiment_completed included, opens an iteration group, so the Markdown can show empty "Iteration N" headings.

Over REST

The audit routes, which list entries, lock a decision, add an event, and export the Markdown, are described in the REST API reference under Sessions, and GET .../audit/config-changes is listed under Experiments.

How ALchemist does it

The session creates one EventEmitter and hands it to its work queue, so queue events and session events arrive on the same object. AuditLog in alchemist_core/audit_log.py appends AuditEntry objects and makes no other change to them; the lock_* methods on the session add the search-space snapshot and the iteration number before they call it.