Observability¶
Domain observations as typed events with pluggable logger sinks. The pure core emits events as
plain values; a Logger attached at the app boundary interprets them (the null default discards
them). Solver diagnostics ride stdlib logging instead — two channels, two mechanisms.
Domain-observation events and the pluggable logger sink (effects-as-data observability).
This is the domain observations channel – distinct from stdlib logging, which carries
solver diagnostics. The pure core emits typed
events as plain values; a Logger attached at the app/ boundary interprets them. The
core installs no sink and runs correctly with the NULL_LOGGER default, so it stays pure,
testable, and vendor-free: Weights & Biases / Comet ML / CSV live only in logger backends at the
boundary (diffBloch.app.loggers), never in the maths.
The name follows the PyTorch-Lightning convention (WandbLogger / CometLogger / CSVLogger
plug into a common Logger); it is the experiment-tracking sink, orthogonal to the stdlib
logging.Logger used for diagnostics. Every Event exposes a uniform
(channel, measurements) surface – the Phoenix :telemetry “named event + measurements” idea
– so a generic logger consumes any event without knowing its concrete type; adding an event never
touches a logger. Callers wanting richer handling can still pattern-match the concrete dataclass.
Events fall into two families: a per-unit stream (a RotationScored per rotation, a
RefinementStep per optimizer iteration – each carries a step) and a run-level
aggregate (InferenceCompleted, RefinementCompleted – step is None).
- class diffBloch.observability.CouplingSummary(measurements: Mapping[str, float])[source][source]¶
Bases:
objectRun-level summary of the plan the refinement/inference consumes (on the coupling channel).
The aggregate companion to the per-rotation
RotationCoupling(stepNonevs a rotation index separates the two on one channel):measurementsisdiffBloch.preprocess.plan.summarize_plan()– the structure-factor support size/radius plus the coupling aggregates across rotations. Emitted once at the consumer boundary.
- class diffBloch.observability.ConvergenceTrial(control: str, trial_index: int, pass_index: int, previous: float, candidate: float, r_factor: float, n_compared_hkl: int)[source][source]¶
Bases:
objectOne comparison between consecutive numerical settings in a convergence sweep.
- class diffBloch.observability.ConvergencePassStarted(pass_index: int, g_max: float, sg_max: float, tilt_steps: int, r_factor_threshold: float, n_orientations: int)[source][source]¶
Bases:
objectStarting settings for one coordinated convergence pass.
- class diffBloch.observability.ConvergenceSweepStarted(control: str, pass_index: int)[source][source]¶
Bases:
objectAnnouncement emitted before one parameter sweep begins.
- class diffBloch.observability.DeviceSelected(requested: str, selected: str, cuda_available: bool)[source][source]¶
Bases:
objectExecution-device selection for an app run.
Device placement is an execution knob, not scientific provenance. This run-level event makes the selected backend visible to console/CSV/vendor sinks without entering config or checkpoint identity. Presentation wording stays with concrete logger backends; this event carries only stable selection data plus numeric measurements for generic metric sinks.
- class diffBloch.observability.Event(*args, **kwargs)[source][source]¶
Bases:
ProtocolA named domain observation carrying numeric measurements.
channelis the event’s stable name – usually a class constant, but read as a plain attribute so an event may set it per instance (e.g.PlanStepCompleteduses the pipeline step’s name).measurementsmaps metric name to value;stepis the optional position on the run’s x-axis (a rotation index, later a refinement iteration) orNonefor a run-level aggregate. Together they let a generic logger record and place any event, no per-type view.
- class diffBloch.observability.ExperimentDeclared(name: str, structure: str, experimental_data: str, optimizer: str, seed_thicknesses_by_dataset: tuple[tuple[str, tuple[float, ...]], ...], integration_semiangles: tuple[float, ...], rocking_curve_sampling: int, dsg: float, rsg: float, solve_g_max: float, sg_max: float, absorption: bool, steps: int, learning_rate: float)[source][source]¶
Bases:
objectThe run’s identity and its result-determining knobs, declared once before any compute.
The counterpart to
ObjectiveManifestfor everything the objective does not cover: which inputs are being refined and under which simulation/optimizer settings. A sink that writes a standalone artifact (the refinement report) needs this to describe the run without being handed theExperimentConfigdirectly – which is what keeps such a sink an ordinaryLoggerrather than a component wired into the app’s orchestration.Paths, the optimizer name, and the per-dataset seed-thickness declarations ride on the dataclass rather than in
measurements, which stays flat-scalar for the generic backends – the same splitThicknessOptimizedmakes for its candidate grid.
- class diffBloch.observability.InferenceCompleted(n_rotations: int, n_evaluated: int, mean_r_obs: float)[source][source]¶
Bases:
objectThe run-level aggregate, emitted once when
run_inferencefinishes.
- class diffBloch.observability.Logger(*args, **kwargs)[source][source]¶
Bases:
ProtocolA sink for domain-observation events, attached at the app boundary.
A logger performs I/O (print, CSV row,
wandb.log); the core only hands it values. The core defaults toNULL_LOGGERso it installs no sink and can run with none attached. Implement a single method to add a backend – seediffBloch.app.loggers.
- class diffBloch.observability.MultiLogger(loggers: tuple[Logger, ...])[source][source]¶
Bases:
objectFan each event out to several loggers (e.g. console and wandb at once).
- class diffBloch.observability.NullLogger[source][source]¶
Bases:
objectThe default sink: discards every event, so the core runs with no logger attached.
- class diffBloch.observability.ObjectiveManifest(penalties: tuple[ObjectiveTerm, ...] = (), constraints: tuple[str, ...] = (), components: tuple[str, ...] = ())[source][source]¶
Bases:
objectWhat the refinement objective is composed of, declared once before the first step.
The refinement-side counterpart to the preprocess pipeline’s
StepRecordprovenance: penalties, constraints, and components are typed Python composition rather than config, so nothing else in a run states which of them are actually in play. This says so up front, before any compute – the “startup summary listing which restraints are active with which weights” that a bare per-epoch loss cannot provide.Reporting the empty case is the point as much as the populated one: the default CLI path composes no penalties at all, and a run that says
penalties: noneis making a scientific fact legible rather than leaving it to be inferred from a missing line.measurementscarries the three counts plus each penalty’s declared weight; the categorical names ride on the dataclass for a backend that pattern-matches it (asThicknessOptimizeddoes for its candidate grid).This is a report, not an identity: it is deliberately not folded into
refinement.lockorrefinement_config_digest(). Refinement outputs are not checkpoint-reused, so hashing a composed-recipe axis would be identity infrastructure built ahead of the need for it.- penalties: tuple[ObjectiveTerm, ...] = ()¶
- class diffBloch.observability.ObjectiveTerm(name: str, weight: float)[source][source]¶
Bases:
objectOne declared soft-penalty term: the objective name it reports under and its weight.
- class diffBloch.observability.OrientationOptimized(rotation_index: int, score: float, residual: str, n_matched_hkl: int, n_trials: int, n_passes: int, pass_cap: int)[source][source]¶
Bases:
objectOne rotation’s finished orientation search, emitted per rotation by
optimize_orientation.The fit is the long phase of a run (a coupled search solves ~100+ trials per rotation), so this is the progress stream that makes it observable:
rotation_indexis the original zero-based PETS rotation index,scorethe final orientation’s value underresidual– theLossMetricsConfigname ("wr2"/"robs") that produced it, carried alongside so a consumer can label the number correctly (measurementskeys on it directly, e.g.{"wr2": ...}or{"robs": ...}) rather than a generic, misleadingwr2field under a different residual.n_trialsthe number of trial orientations the search scored,n_passesscipy’s reported iteration count (the quantityNelderMeadSearch.max_iterationscaps), andpass_capthat cap itself – carried per event so a plot can show each rotation’s headroom (n_passesvspass_cap) and flag any rotation that ran to the cap. Withworkers > 1events arrive in completion order (the plan itself stays ordered). The channel is shared with the step’sPlanStepCompletedsummary line, like the refinement stream’s events.
- class diffBloch.observability.OrientationOptimizationStarted(total_rotations: int)[source][source]¶
Bases:
objectThe rotation count
optimize_orientationis about to search, emitted once before any of it.Exists so a progress display can show a countdown (
n_seen / total_rotations) againstOrientationOptimizedwithout needing to know the plan size in advance – the plan is only assembled deep inside the step itself. Deliberately a distinct channel fromOrientationOptimized(not merely a different type) – a consumer such asEarlyAbortLoggerthat filters byevent.channelalone must not mistake this for a per-rotation result.
- class diffBloch.observability.OrientationOptimizationSummary(n_orientations: int, mean_score: float, residual: str, unique_matched_hkl: int, unique_strong_hkl: int, unique_observed_hkl: int, total_trials: int, max_passes: int)[source][source]¶
Bases:
objectAggregate statistics after every rotation’s orientation fit has completed.
unique_*counts are deduplicated distinct(h, k, l)counts across every rotation’s own set (unique_hkl_count()), not a sum of each rotation’s own count – a reflection re-observed (or matched) in more than one rotation is counted once, not once per rotation.unique_strong_hklis “matched and I > 3*sigma in at least one rotation” – the same reflection can be strong in one rotation and weak in another, so this is a lower bound on “genuinely always weak,” not a claim every occurrence was strong.
- class diffBloch.observability.PlanSeeded(measurements: Mapping[str, float])[source][source]¶
Bases:
objectThe Plan a preprocess pipeline is about to run on, summarised before the first step.
Exists so every
PlanStepCompletedhas a predecessor to be read against: a step’s counts are only a survival count if the incoming counts were reported too, and the seed is produced byfrom_experiment(or loaded from a checkpoint on resume) rather than by any step, so noPlanStepCompletedcovers it.measurementsisdiffBloch.preprocess.plan.summarize_plan()of that incoming plan.Deliberately a distinct channel from the per-step stream, and
stepisNone: a consumer filtering on channel alone must not mistake the baseline for a stage result.
- class diffBloch.observability.PlanStepCompleted(channel: str, index: int, measurements: Mapping[str, float])[source][source]¶
Bases:
objectThe Plan produced by one preprocess pipeline step, summarised as the recipe runs.
Unlike the other events its
channelis the step name (select_beams,optimize_orientation, …), set per instance rather than a class constant – so the console readsoptimize_orientation[4] n_orientations=55 ..., carrying the categorical step identity a fixed channel cannot.indexis the step’s ordinal in the recipe (itsstepon the run’s x-axis);measurementsisdiffBloch.preprocess.plan.summarize_plan()of the resulting plan. Emitted only on a fresh preprocess run – a reused checkpoint runs no steps.
- class diffBloch.observability.RecordingLogger(events: list[Event] = <factory>)[source][source]¶
Bases:
objectAn in-memory logger that keeps every event (the doc’s “in-memory history” sink).
A shippable backend – useful for post-hoc inspection of a run and as the natural test double (assert on
eventsinstead of scraping a console). Unlike the vendor backends it performs no external I/O, so it stays vendor-free here besideNullLogger/MultiLogger.
- class diffBloch.observability.RefinedRotationMetrics(rotation_index: int, wr2: float, r_obs: float, n_matched: int, is_validation: bool)[source][source]¶
Bases:
objectOne rotation’s wR2/R_obs under the final refined model, emitted after the loop.
Distinct from
RefinementOrientationStep, which is a per-epoch training diagnostic: this is the settled result, scored once on the best model by the reporting engine, so it covers every rotation including the held-out ones (is_validationmarks those). The refinement loop cannot emit it – the loop only ever sees the training engine – so the app boundary emits it once the run has finished.
- class diffBloch.observability.RefinementCompleted(n_steps: int, best_step: int, best_loss: float, selection: Literal['training', 'validation']='training', reflection_counts: Mapping[str, int]=<factory>)[source][source]¶
Bases:
objectThe refinement-run aggregate, emitted once when
run_refinementfinishes.Shares the
"refinement"channel withRefinementStep, separated from the stream bystep(the iteration index vsNone). The two are not the same quantity at different granularities:RefinementStepalways reports the training objective, whereasbest_lossis whichever objective actually selected the epoch.selectionnames that objective –"training"by default, or"validation"whenrun_refinement_modelwas given a held-out selection engine.Because those two populations are not comparable,
measurementsemitsbest_lossunder a different key per mode (best_training_loss/best_validation_loss) rather than one shared key plus a flag. A generic backend cannot then plot a train-selected and a val-selected run as one series: the key is absent instead of silently wrong.
- class diffBloch.observability.RefinementOrientationStep(iteration: int, rotation_index: int, wr2: float | None = None, r_obs: float | None = None, diff_loss: float | None = None)[source][source]¶
Bases:
objectOne rotation’s wR2/R_obs/diffraction-loss diagnostics within a refinement epoch.
The per-orientation companion to
RefinementStep’s epoch mean:run_refinement_modelemits one of these per rotation per step only when itsverboseflag is set (the “verbose refinement” reporting mode) – the per-rotation stream isn_orientations``x louder than the epoch summary, so it is a diagnosis tool, not the default reporting shape. ``iterationplaces it on the same x-axis asRefinementStep;rotation_index(the original zero-based PETS rotation index) is this event’sstep, matching the per-rotation convention ofRotationScored/OrientationOptimized.
- class diffBloch.observability.RefinementOutputsWritten(structure: str, artifacts: Mapping[str, str]=<factory>)[source][source]¶
Bases:
objectThe refined artifacts are on disk – the run’s terminal event.
This is what lets a report be a plain
Loggerdespite having to be written exactly once, after everything else, without adding aclose/finalizemethod to the protocol: a sink that must finish at the end simply acts on this event. Putting the lifecycle in the stream keeps it observable (aRecordingLoggershows it) instead of implicit in a call order.structureis the path to the writtenrefined_structure.cif. A sink reads it back rather than being handed parsed values, so anything it reports about the structure is byte-consistent with the committed file by construction.
- class diffBloch.observability.RefinementStarted(total_steps: int)[source][source]¶
Bases:
objectThe epoch budget
run_refinement_modelis about to run, emitted once before the loop.Exists so a progress display can show a countdown (
iteration / total_steps) againstRefinementStepwithout needing the config’srefinement.stepspassed in separately. Deliberately a distinct channel fromRefinementStep(not merely a different type) – a consumer that filters byevent.channelalone must not mistake this for a per-epoch result.
- class diffBloch.observability.RefinementStep(iteration: int, loss: float, wr2: float | None = None, r_obs: float | None = None, diff_loss: float | None = None, objective_total: float | None = None, components: Mapping[str, ~collections.abc.Mapping[str, float]]=<factory>, n_rotations: int | None = None, n_wr2_evaluated: int | None = None, n_r_obs_evaluated: int | None = None)[source][source]¶
Bases:
objectOne refinement epoch.
wr2/r_obsare always-computed reporting diagnostics (mean weighted-R2 / R_obs across orientations), free regardless ofExperimentConfig.loss_metrics(which decides whatlossactually minimises, not what gets reported here) – so both are always shown. Contrast the preprocessing search’s events (OrientationOptimized/ThicknessOptimized), which report only the configured residual, since computing the other would cost an extra solve.componentscarries each named objective term’srawscientific diagnostic, itsweight, and thecontributionthat weight produces, andmeasurementsflattens every one of them to a"{term}/{field}"key so the generic backends (console, CSV, W&B, Comet) report a restraint’s state without knowing any term by name. A term that was never composed into the objective has no entry, so it cannot surface as a satisfied0.0; that absence is the reportable fact, and it is why the flattening is unconditional rather than keyed on a fixed term list.wr2/r_obsare means over the rotations that produced a finite score, so each carries its own denominator:n_rotationsis how many the objective covered (the training set when a validation split is on) andn_wr2_evaluated/n_r_obs_evaluatedhow many actually entered each mean. They are separate counts because the two metrics are NaN-filtered independently – a rotation can contribute to one and not the other – and a mean whose denominator is implicit can improve simply by evaluating fewer rotations. CompareInferenceCompleted, which has always reportedn_evaluatedbeside its mean.
- class diffBloch.observability.RotationCoupling(index: int, n_coupling_segments: int, n_tilts: int, max_tilts_per_segment: int, n_union_beams: int, max_beams_per_segment: int)[source][source]¶
Bases:
objectOne rotation’s coupled solve geometry, emitted per rotation at the consumer boundary.
The shape the refinement loop repeats every step:
n_coupling_segmentscoupled unions overn_tiltsrocking-curve tilts, the widest union spanningmax_tilts_per_segmenttilts, the deduped union carryingn_union_beamsbeams, and the largest single segmentmax_beams_per_segmentbeams – theNof the dominant per-segment eigensolve. Fires on every run (fresh or checkpoint-reuse), so the coupling a long refine is about to chew on is legible before the first step.
- class diffBloch.observability.RotationScored(index: int, r_obs: float, n_observed: int, n_beams: int)[source][source]¶
Bases:
objectOne rotation’s forward-inference score, emitted per rotation by
run_inference.
- class diffBloch.observability.ThicknessOptimized(rotation_index: int, score: float, residual: str, thickness: float, candidate_thicknesses: tuple[float, ...], candidate_score: tuple[float, ...])[source][source]¶
Bases:
objectOne rotation’s finished thickness grid search, emitted per rotation by
optimize_thickness.The thickness fit is the memory-heavy tail phase (each rotation scores the whole
ThicknessGridin one segmented solve), so likeOrientationOptimizedthis makes it a progress stream rather than a silent block:rotation_indexis the original zero-based PETS rotation index,scorethe baked thickness’s value underresidual– theLossMetricsConfigname ("wr2"/"robs") that produced it, carried alongside so a consumer can label the number correctly (measurementskeys on it directly) rather than a generic, misleadingwr2field under a different residual, andthicknessthat winning candidate (Angstrom).candidate_thicknesses/candidate_scorecarry the whole scored grid (same order, one entry perThicknessGridstep) – deliberately excluded frommeasurements(which stays flat-scalar for the generic console/CSV/wandb/comet backends); a plotting backend such asThicknessPlotLoggerpattern-matches the concrete dataclass to read them. Emitted in plan order (the fit is sequential).
- class diffBloch.observability.ThicknessOptimizationStarted(total_rotations: int)[source][source]¶
Bases:
objectThe rotation count
optimize_thicknessis about to grid-search, emitted once up front.Exists so a progress display can show a countdown (
n_seen / total_rotations) againstThicknessOptimizedwithout needing to know the plan size in advance – mirrorsOrientationOptimizationStarted. Deliberately a distinct channel fromThicknessOptimized(not merely a different type) – a consumer such asEarlyAbortLoggerthat filters byevent.channelalone must not mistake this for a per-rotation result.
- class diffBloch.observability.ThicknessProfile(form: str, min_thickness: float, max_thickness: float, rotation_indices: tuple[int, ...], alphas: tuple[float, ...], thicknesses: tuple[float, ...], label: str)[source][source]¶
Bases:
objectOne dataset’s trained apparent-thickness curve, sampled at its rotations’ tilt angles.
Emitted once per composed thickness network after refinement – one event per dataset, each labeled by its
inputs.exp_dataref. The whole curve rides on the dataclass as parallel tuples (one entry per rotation, in plan order) rather than as ~100 separate events or ~300 flat measurement keys – the shapeThicknessOptimizedalready uses for its candidate grid.measurementscarries only the scalar summary.channelembeds the label (the per-instance form theEventprotocol anticipates) so metric sinks that key series onchannel/namekeep pooled datasets’ curves apart.