Wavelength lookup tables as streamed context inputs (ADR 0010/0011) - #1235
Conversation
e828240 to
692c7ef
Compare
Records the design for feeding the wavelength-LUT workflow's output back into backend workflows as a context input, replacing the hard-coded per-instrument LUT files. Status is proposed; one open item (opt-in vs universal clear-on-change) is still under discussion. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
sc.identical returns False for two bit-identical arrays containing NaN, and lookup tables carry all-NaN rows wherever the chopper cascade blocks the beam. As written the mechanism would have cleared every consumer on every republish. Use sc.allclose(..., equal_nan=True) plus explicit coord comparison, whose tolerance doubles as the noise-rejection knob. Also records that motion and LUT clearing are orthogonal because the range is static -- a property of the static-range decision that the original text did not credit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comparing table contents at the consumer put the noise-rejection knob far from the setpoint jitter it filters, and needed N comparisons plus a NaN-safe primitive. A fingerprint derived from the producer's inputs is one scalar, survives a producer restart, and puts the rounding precision at the source. Resetting on any received LUT is simpler still but would clear every consumer when the LUT job is restarted, which is the v0 recovery action, and would make the planned liveness heartbeat clear the facility. Also records consuming component motion in the LUT workflow as a considered alternative: it would remove the static travel envelope, at the cost of gating the LUT job on motion. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Scope, v0 rollout and the instrument applicability matrix are plan material, not decision record, and move to the implementation notes. Line references are dropped throughout: they go stale, and the symbols they pointed at are stable enough to name directly. Standing limitations stay, reframed as consequences of the decision rather than as things accepted for a release. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Coordinate mode is now a property of the spec rather than a runtime parameter, for detector views and monitors alike. Gating is resolved per (workflow_id, source_name) and never per parameter value, so once the wavelength path consumes a lookup table as gated context a combined spec would gate its TOA jobs on a table they never read (ADR 0010). TOA is the mode you fall back to when everything else is broken; it must not depend on the most fragile link in the chain. DREAM and LOKI gain wavelength-variant detector-view and monitor specs. The existing combined specs are untouched, so the file-based path stays available to compare against the streamed one during commissioning. Making "this spec cannot do wavelength" expressible exposed specs that were already claiming otherwise. Every logical view offered wavelength in the UI and raised at job start: they run on InstrumentDetectorSource, which carries no geometry, so there is no Ltotal to index a table with. Three geometric specs had the same defect. Monitor output templates hard-coded a time_of_arrival coord, so a wavelength monitor output never matched its declared model -- invisible until now because the validation test only ever ran default parameters. The new test pins the invariant behind all of these: a spec whose parameters accept wavelength must actually build in wavelength mode.
…pecs Reverts the spec split. Coordinate mode goes back to being a parameter on a single workflow, because the dashboard's data plane is keyed by (workflow_id, source_name, output_name): two specs give the two modes different output identities, so a plot cannot follow a mode switch and the operator runs two jobs where one would do. Coordinate mode describes how you are looking at a detector, not which detector you are looking at. The split existed to stop a wavelength lookup table, delivered as gated context, from also gating time-of-arrival jobs that never read it. That turns out not to require a split: the gating set is resolved at a single call site inside job creation, which already holds the job's parameters, so the gate can be narrowed per job. ADR 0003 named param-dependent gating a non-goal on YAGNI grounds when the only over-gated stream was an always-on control-system PV; both of those premises have expired. The predicate itself is not built here -- it has no consumer until there is a lookup-table binding to attach it to. What survives the reversal is the part the split made visible: specs that cannot convert to wavelength now say so, rather than offering a mode that raises at job start.
The per-component Ltotal rule was justified by indirect geometry putting a table "tens of metres" from where it is queried. The secondary flight path is metres against primary paths of tens to hundreds of metres, so padding absorbs the difference and the declaration buys exactness the table's distance resolution does not reward. Also pins the consumer seam to essreduce's public LookupTable dataclass rather than the file loader's backwards-compatibility branch, and records the DREAM+LOKI scope and the gate-resolution interface change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A workflow output named in WorkflowSpec.context_outputs is republished on a dedicated topic under a stable, job-identity-free stream name, where it becomes an ordinary context input for other workflows to bind (ADR 0010). This is the publish half only; nothing consumes the topic yet. Modelled on the NICOS derived-device mirror, but without its contract object: context stream names need one query (which outputs does this job republish), not a static export or dashboard lookups, so the registry is resolved once in the extractor's constructor. That makes a colliding or malformed declaration a startup failure rather than a surprise on the first result. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds the read half of the mirror: a dedicated topic per instrument, a da00 route carrying no stream lookup table so the internal stream name is the da00 source name, and a LatestValueAccumulator for the new kind in the detector and reduction preprocessor factories. That accumulator is already marked as context, so the context cache and the JobManager gate need no change. Route derivation gathers a bound context stream name and then drops it, since it appears in no stream lookup table. That is correct -- the topic is routed unconditionally rather than derived from the mapping -- but it reads as a bug, so it is pinned by a test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A table is indexed by distance and a lookup outside its range yields NaN with no error anywhere, so the range must be expressed in the same Ltotal the consumer uses at lookup time. Rather than re-deriving that definition, the range runs essreduce's own DetectorLtotal and MonitorLtotal providers, which is what makes the range and the lookup agree by construction. On DREAM each component spans 2-11 distance rows at the default 0.1 m resolution, against roughly 730 for one table covering source to detector. A component hanging off a live f144-driven transform raises instead of guessing: the artifact carries no nominal value for it, and the alternative is a table silently placed at the wrong distance. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The workflow keeps its single chopper_cascade source and now publishes one table per detector and monitor, each covering that component's own flight-path range, declared as context outputs so consumers can bind them. The user-facing distance range parameter is gone: it defaulted to a span that covered no instrument correctly, and an operator starting the workflow with defaults could silently blank every detector. The cascade is still computed once per trigger; only the polygon rasterization runs per component, over a handful of distance rows instead of hundreds. The integration tests no longer assert finite wavelengths in a table. Every chopper is fed the same placeholder 14 Hz / zero delay, which blocks the beam a few metres past the first chopper, so a table at a real component's distance is legitimately all-NaN. Those assertions only passed before because the fixed 5-30 m range reached upstream of the first chopper, where nothing is blocked yet. The cascade-bands diagnostic carries the assertion instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Coordinate mode is a parameter, so one spec serves both time-of-arrival and wavelength. Gating every job of such a spec on a lookup table would make TOA -- the mode you fall back to when everything else is broken -- depend on an operator-started job that re-emits only on chopper change. A ContextBinding can now carry a predicate over the job's params, so a TOA job resolves an empty gating set while a wavelength job gates on its table. Params validation moves up into job creation and WorkflowFactory.create takes the validated model, so the order is validate -> resolve gate -> build with one validation site rather than two that can drift. Resolution splits in two rather than taking an optional params argument. declared_context_keys ignores predicates and serves the static callers -- route derivation and the workflow visualizer -- which have no job and specifically need the superset; resolve_context_keys filters it for the job path. A predicate can only remove bindings, so the statically derived Kafka subscriptions stay a conservative superset of any resolved gate. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Detector and monitor views bind their component's table as gated context and no longer take a lookup-table filename at all: the argument is gone from both factories, so a view has no file path left to fall back to. The table is reassembled into essreduce's public LookupTable dataclass from the wire coords rather than round-tripped through its file loader, whose matching branch is a backwards-compatibility shim that cannot carry chopper provenance. The context key is wired unconditionally while the binding's predicate alone decides gating. Branching in both places would mean two conditions that have to agree, with nothing to catch a disagreement; a context key no provider reaches is a verified no-op. LOKI I(Q) keeps its file table for now. Unlike a view it needs one table per sciline Component -- the detector plus the incident and transmission monitor roles -- and which monitor plays each role is a per-job aux selection that a spec-scope binding declared at import time cannot know. Migrating it needs the job-creation check ADR 0010 leaves open. The end-to-end test runs the real chain on DREAM: the LUT job computes per-component tables, they are extracted, serialized to da00, ingested back through the Kafka route, and reduced with by a wavelength-mode monitor job. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three related changes. A component riding an f144-driven axis has no position in the geometry artifact, which stores such a transform as an empty NXlog: neither its resting value nor its travel can be recovered. Both become instrument declarations, one MotionEnvelope per axis keyed by NeXus transform path. Which components ride an axis stays derived -- a component is affected precisely when the axis appears in its depends_on chain -- so one hung off it later inherits the envelope instead of silently getting a nominal-only range. LOKI's carriage rests at 0 and travels 15 m, which places the rear bank at 28.5 .. 43.9 m. Consumers now bind only components the lookup-table workflow can actually place. Binding a stream that is never published would leave the job gated forever, and doing it for a component nobody asked about would take unrelated jobs down with it -- which is exactly what beam_monitor_m4, on an undeclared axis, would have done to every LOKI I(Q) job. I(Q) takes its tables from the stream too. It needs one per sciline Component, and which monitor fills the incident or transmission role is a per-job aux selection that an import-time binding cannot know. So every candidate monitor binds its own key and the factory, which does see the selection, maps the chosen ones onto the roles; the unselected keys are dead parameters that set_context stores and computes nothing from. Gating on all candidates costs nothing because they all come from one job, split out of one result, so they arrive together. This retires ADR 0010's proposed job-creation check that would have raised when the chosen monitor differed from the bound one. The wavelength-mode predicate no longer tolerates a params model without a coordinate mode. Reading the missing field as "not wavelength" left I(Q) ungated and handed its providers a table that never arrived. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three corrections of record. A live transform is stored as an empty NXlog, so the artifact withholds the component's resting position as well as its travel; both are declarations, one MotionEnvelope per axis keyed by transform path. Which components ride an axis is derived from their depends_on chains rather than from chain_patch_bindings, which is the honest source and covers components no binding mentions. A component on an undeclared axis gets no table and is bound nowhere, rather than gating a job on a stream that is never published. Binding every candidate monitor replaces the planned job-creation check on the aux selection: the tables all come from one job and arrive together, so gating on all of them opens the gate at the same instant as gating on one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The consuming factories repeated the lookup table's stream-name-to-key mapping that the spec's ContextBinding already declares: the detector view and the monitor workflow set it as a constructor context key, and LOKI's I(Q) listed every candidate table again. The copies were redundant -- WorkflowFactory.create injects the resolved bindings via SupportsContext.build before the StreamProcessor is baked -- and dead for TOA jobs, whose predicate-filtered gate never delivers the stream. Worse, they were a second declaration that had to agree with the binding with nothing to catch a disagreement: the routing-vs-wiring drift ADR 0003 was written to remove, reintroduced in a new spot. The factories now contribute only the reassembly provider, still inserted unconditionally; a provider whose input never arrives is dead graph. All stream-name knowledge lives on the binding.
Which monitor fills the incident or transmission role is a per-job aux
selection, which an import-time ContextBinding could not name. The
previous shape bound every candidate monitor to its own synthesized
context key and had the factory map the chosen ones onto the roles:
dead parameters for the unselected keys, a fresh-NewType-per-call
footgun in component_lut_context, and the candidate list restated in
the factory.
A binding's stream name may now carry an aux-field placeholder
(wavelength_lut/{incident_monitor}), rendered against the job's
rendered aux selections during gate resolution, which already runs at
the one call site holding them. One binding per role, a gate covering
exactly the selected monitors' tables, and no per-candidate keys
anywhere. declared_context_keys leaves templates unrendered; route
derivation expands them over the aux field's declared choices so the
statically derived subscriptions stay a superset of any rendered gate.
Two roles selecting one monitor would resolve one stream to two
conflicting keys and are rejected at job creation; a monitor whose
table the LUT workflow cannot publish is rejected by the I(Q) factory,
since a gate on it would wait forever (previously such monitors were
silently left unbound).
The new conflict check surfaced that Instrument.load_factories was not
idempotent: every call re-appended context bindings, and the
synthesized per-chopper setpoint keys are fresh objects on each run, so
repeated loading (as pytest collection does) left duplicate bindings
with unequal keys that the old dict comprehension silently collapsed.
It now no-ops on a loaded instrument.
Also drops the stale claim that chopper provenance already travels in
the table's identity; the identity stamp is specified in ADR 0010 but
not yet implemented.
The both-modes and TOA-only variants of the detector-view and monitor
params models each restated the toa_range/toa_edges field pair, and the
copies had already drifted in their descriptions ("in TOA mode" vs
plain). One fields-only mixin per module now carries the pair; the
descriptions unify to the mode-neutral wording, which is correct for
both variants. get_active_edges/get_active_range stay on the concrete
models: a mixin answering "what is active" with TOA would let a future
both-modes model silently inherit the wrong answer.
Field order, titles, and defaults are unchanged, so the rendered UI
forms and serialized params are identical apart from the description
strings.
The DREAM and LOKI detector-view wrappers existed to resolve a lookup-table filename before delegating; with the table streamed as context they had decayed to pure passthroughs. Attach make_workflow itself: attach_factory infers the params model from the signature, and the spec-registered params are subclasses of DetectorViewParamsBase.
Widening the transmission choices to M3/M4 made an option selectable that a wavelength reduction cannot honour. The geometry artifact drives beam_monitor_m4's position from a live f144 NXlog but records its source as the literal string 'source' rather than a PV -- the only stream in the LOKI artifact with that defect -- so the monitor cannot be placed, gets no lookup table, and the I(Q) factory rejects it. That rejection is correct and already tested, but it lands only after the user has configured and started a job. State the limitation where the workflow is chosen instead. It goes on the spec description because the aux selector renders only AuxInput.title, so an AuxInput.description saying the same thing would never be seen. A MotionEnvelope is deliberately not the fix here: it would make the range derivable and let the job build, only to fail deeper in the reduction on the unpatched time-dependent transform, since a chain-patch binding needs the PV name the artifact does not carry. Also pin that the monitor defaults stay placeable, so changing a default cannot break I(Q) for anyone who never touches the selectors, and repair a garbled sentence in the same description.
Services now consume <instrument>_livedata_context, but ensure_topics_exist enumerated the infrastructure topics by hand and was not updated, so every integration test failed on a pristine broker with "Topics not found: ['dummy_livedata_context']". Derive the set from the LivedataTopics fields instead, so a new topic cannot leave topic creation behind again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
MotionEnvelope named a `nominal` that NXtransformations has no concept of: the artifact stores the live transform as an empty NXlog, so its geometry corresponds to no axis value at all and `nominal` was only the lower end of the interval. `travel` was worse than its name -- declared in metres and added straight onto the Ltotal stop, it silently assumed the axis vector runs along the beam and that increasing values move downstream. LOKI's carriage happens to satisfy both. AxisRange declares both bounds as axis values in the axis's own units, so the transform supplies the direction and sense of the motion: the range comes from evaluating the geometry at the corners of the box the bounds span, not from assuming which way along the beam the axis travels. The same two numbers, now in the same space. Pixel positions are affine in a translation's value, so Ltotal is convex and its maximum is attained at a corner; padding absorbs the interior minimum a component crossing the sample plane could have. An angle has no such property, so a live rotation axis is refused rather than covered approximately, and its component falls into the existing no-table path instead of getting one too narrow for its swing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Match DeviceContract.devices_for, which looks up by the WorkflowId object. The string form was internal to this module, so keying on the model itself drops the per-result f-string from the extractor's hot path.
The 0-D coords carrying the LookupTable dataclass's scalar fields were
taken from the job's parameters, on the reasoning that this keeps units
user-facing. But essreduce defines those fields as properties of the
table that was built ("Resolution of the distance coordinate in the
lookup table"), not as a record of what was requested, and the two
differ: the builder fits a whole number of bins into the frame period,
so a requested 250 us becomes 249.75 us at the default 14 Hz.
pulse_stride already had to be excepted from the params rule when
chopper-based auto-detection made params wrong. That was the same
failure, fixed pointwise; this drops the rule instead. Input provenance
belongs to the identity coord (ADR 0010), which leaves these coords free
to describe the table alone.
No behaviour change: the two resolutions are write-only in essreduce,
read by nothing downstream.
A lookup table is a function of distance and event_time_offset alone, so a per-component table is merely a restriction of the same function. Publishing N of them cost an outputs model generated per instrument, a stream, binding and sciline key per component, a dashboard plot list that grew with the instrument, and per-job monitor selection expressed in stream names. Components that share a stretch of beamline now share a table: one for the detectors, one for the monitors. Neither is a single uniform grid -- monitors sit tens to hundreds of metres upstream -- so a table is a concatenation of uniform blocks, dense across the detectors and a few rows at each monitor. LOKI's four monitors cost 26 rows in total. essreduce's numba interpolator locates a row assuming a uniform axis, so a consumer selects its own block before the table reaches essreduce, matching the Ltotal its graph already computes against the distances the table already carries. Component identity therefore leaves the wire entirely, and with it the aux-templated stream names: both I(Q) monitor roles bind the one monitor stream and a provider generic in MonitorType serves every monitor role. Also drops component_ltotal_ranges, whose last caller was its own test.
A ContextBinding's stream name could carry a {aux_field} placeholder that gate
resolution rendered against the job's aux selections, with route derivation
expanding it over the field's declared choices so Kafka subscriptions stayed a
superset of anything a job could render. Its only user was the I(Q) reduction
picking the streamed lookup table of whichever monitor filled its incident or
transmission role; sharing one monitor table removed that need -- a role now
selects its rows by flight path -- and nothing else templated a stream name.
Stream names are therefore fixed at declaration time again, which is what the
subscription derivation and the no-job-identity rule (ADR 0006) both want.
resolve_context_keys loses its aux_source_names parameter, and its
conflicting-key check now reports what remains possible: two bindings naming
one stream for different purposes, which is a declaration mistake rather than
something a job's selection can cause.
A context_outputs entry was a stream-name template, rendered once per source name the publishing spec declares. Nothing rendered one: the only declaration in the tree names two fixed streams on a one-source spec, and the placeholder was exercised by its own tests alone. The consuming side lost the same mechanism in the previous commit, for a reason that applies here too. A context stream carries no job identity (ADR 0006), and a name that varies per job is a name the statically derived Kafka subscription cannot be sure to cover. A spec publishing context outputs therefore has exactly one source name -- until now enforced by accident, as the duplicate-name check firing on a spec colliding with itself, and now a WorkflowSpec validator that says so and fails at registration. device_outputs keeps its templating: a NICOS derived device is named per source by design, and nothing consumes the name back.
Two bindings naming one stream for different Sciline keys was detected while resolving a job's gate and reported per job. Nothing about a job can cause it any more: since stream names stopped being templated, the bindings that apply to a (spec, source) pair are a function of the declarations alone, and a predicate only ever removes one. Worse, a predicate that removed one of the conflicting pair hid the conflict from the check entirely. It joins the collision checks already run over every (spec, source) pair at registration, which leaves declared_context_keys and resolve_context_keys as the same comprehension, one of them filtered.
attach_wavelength_lut_factory returned every derived flight-path range keyed by component, and Instrument kept the mapping only to take its keys: with one table per group the ranges have no reader outside the factory that lays the blocks out. Deriving them per group rather than into one mapping by name also drops the regrouping the caller did immediately afterwards.
Packing the blocks into the published DataArray lived with the producer and unpacking one job's block with the consumer, each spelling out the four scalar LookupTable fields the wire carries as coords. Two tests reached into the producer's private function to build a table at all. pack_blocks and unpack_block are inverses, so they now sit together in lut_blocks -- which already owns the block invariant both depend on -- with one list of fields between them.
A consumer recovered the blocks by looking for where the row spacing jumps: a threshold on a float difference (_SPLIT_FACTOR), which the producer kept satisfiable by merging any two ranges closer than a second, larger threshold (_MERGE_FACTOR) -- larger because the upstream builder pads every block by two resolution steps at each end, so rows sit four steps closer than the ranges they came from. Three quantities had to stay in step, and a test fixture had to claim a 10 km distance resolution to make a two-row table read as one block. The table now carries a block coord, so a boundary is stated rather than inferred. Merging goes with the inference: two monitors close enough to overlap simply get a block each, costing a few duplicated rows, and a job takes the first block covering its flight path. The blocks themselves stay. Rows only where components are is what keeps the message small: BIFROST's 155 m span is 3.5 MB at a 0.1 m resolution against a broker's 1 MB default, and 35 MB at 0.01 m, to carry what fits in tens of kilobytes.
Every view passed the group whose table it binds -- detector views the detectors, monitor views the monitors -- so the argument said something only at LOKI's I(Q), which binds the monitor table on its detector sources because its monitors arrive as an aux selection. It now defaults to the group, leaving that one call as the only place the two differ.
2b64edf to
0613c98
Compare
Whether a job reads the streamed lookup table was stated twice: the factory inserted the reassembly provider, and the instrument declared a ContextBinding saying which specs, sources and params gate on it. Two statements of one fact that can disagree -- and the silent direction of the disagreement leaves a job in pending_context forever, waiting for a stream it would never read. The declaration form also could not express a spec reading another group's table without contortion: LOKI's I(Q) had to bind the monitor table against its *detector* sources. The graph already knows. An instrument now declares only which stream carries which workflow key, and StreamProcessorWorkflow.build keeps a declaration when its key is an ancestor of a target key -- the exact condition under which finalize would fail without it. Insertion stays unconditional: a provider the targets cannot reach is pruned, so a time-of-arrival job requests nothing and gates on nothing, with no predicate and no conditional insert saying so. Job creation reads the result back off the workflow, so the order becomes validate -> build -> gate. ContextBinding.predicate loses its only user and goes, along with the declared/resolved split that existed to filter by it. The two mechanisms now divide cleanly: a binding is pushed into a graph that never names it (a chain patch), a declared stream is pulled by a graph that asks for its key. Also fixes visualize_workflows, which called WorkflowFactory.create without params and so failed to create every workflow it tried to render.
The tank and sample rotation keys are ordinary parameters of the cut
workflow, so which jobs wait on them follows from the graph each job
builds. Declaring them as bindings meant restating that by hand:
skip_instrument_contexts() on the detector view and the ratemeter,
maintained in a different file from the graphs it describes. Offering the
streams by key derives the same split -- verified against the real
workflows: the three Q-maps reach both keys, the two opt-outs do not.
A binding is now required only where the key reaches the graph solely
because the binding injects it (a chain patch), or where the stream
filling a key varies per source. Offers are bijective and cannot express
the latter; that, rather than push-vs-pull, is where the line falls.
Renamed accordingly, since the previous vocabulary gave four near-synonyms
on one axis and two inverse maps with near-identical names:
declare_context_stream -> offer_context_stream
Instrument.context_streams (key -> wire name)
-> offered_context_streams
declared_context_keys (wire name -> key)
-> bound_context_keys
core.context_outputs.resolve_context_streams
-> resolve_context_outputs
Route derivation now gathers offered stream names too. It only ever saw
binding names, so moving BIFROST dropped the motion topic subscription
from the reduction service -- caught by the service-level gating tests.
Offered names are validated at registration against what anything
actually publishes, the check bindings already had: a gate on a name
nobody publishes never opens.
Offers cannot be routed precisely: route derivation runs at startup with no params, while an offer is taken up per job from the built graph, so every service hosting any spec subscribes to every offered stream. The wavelength-LUT chopper setpoints are consumed by one workflow on the timeseries service; offering them would add 12 dead subscriptions each to data_reduction, detector_data and monitor_data on BIFROST. The previous docstring listed only injection and per-source variation as reasons to prefer a binding, which reads as an invitation to move them.
The ROI work on main (#1269) reached the same gating machinery from the other side, so the two halves of the gate had to be reconciled: - ROI leaves AuxSources for a non-gating ContextBinding, so the branch's aux_source_names plumbing through the detector-view factory and the per-instrument factory wrappers goes with it. - JobFactory now unions the binding-declared gate with the streams the built graph asks for, instead of taking all bound context keys. The routed set keeps every bound context stream, gating or not. - resolve_gating_streams is renamed bound_gating_streams to pair with the branch's bound_context_keys, against offered_context_streams. - register_detector_view absorbs the spec registration add_logical_view used to inline. Logical views keep their TOA-only params, now stated once in add_logical_view rather than per instrument; MAGIC's geometric projection still passes them explicitly, as it has no lookup table. The seam the merge creates -- a job with both a non-gating binding and a requested offered stream -- is pinned by a new job-manager test.
The amendment described a design that was never built: a gating predicate on ContextBinding, and spec-scope stream names carrying aux-field placeholders rendered from a job's aux selections. Both were removed before the branch settled -- the gate is derived from the built graph, and a context stream name is fixed at declaration time, as the stream_name docstring says. It also claimed the spec split had been rejected in favour of the precise gate, when the split was tried and reverted for an unrelated reason: two specs give the two coordinate modes different output identities. What ADR 0003 needs here is a pointer, not a restatement. Its non-goal still holds as written -- a ContextBinding gates every job on its dependent sources regardless of params, motion included -- so the amendment now says only what ADR 0010 adds alongside it, and where the reasoning lives.
nvaytet
left a comment
There was a problem hiding this comment.
I didn't look at everything yet, but I have a preliminary question.
There was a problem hiding this comment.
I find this ADR much too long. When reading it, it's difficult to make sure we have not missed important bits, as one's focus drifts away half way through.
My guess is that it comes from Claude's OCD to leave no stone un-turned, but there is no way I can fit all this into my head in one go.
ADRs used to be relatively brief and to the point. I don't find keeping in the docs such ADRs useful because no one will read them (apart from AI agents maybe? or maybe that was the point, keep them for a future agent?)
There was a problem hiding this comment.
I agree. I had reviewed the full initial version, but then went through several update rounds where I just looked at amendments, each of which grew it. I'll see how it can be trimmed down (I suppose a lot of the details are just documented in the code itself).
| detector table and a monitor table. The range parameter is removed. One message per output | ||
| follows automatically: `UnrollingSinkAdapter` already splits a multi-output result. | ||
|
|
||
| Two, rather than one per component, because a table is a function of `distance` and |
There was a problem hiding this comment.
In scipp/ess#603 we make one table per component. I don't see an issue in making one for each monitor, apart from maybe displaying the results?
But it would be nice if the workflow here was functioning in the same way as the GenericUnwrapWorkflow?
There was a problem hiding this comment.
Did that in an earlier version of the branch, but it came with several problems.
| ``int((ltotal - first) / (distance[1] - distance[0]))``, which reads the wrong | ||
| row -- silently, and only under numba, since the scipy fallback handles an | ||
| uneven axis correctly. A consumer must therefore select its own block with | ||
| :func:`select_block` and never hand a whole multi-block table to |
There was a problem hiding this comment.
Why are we concatenating if this causes special handling later? Can't we make one uniform table per component and then not have to worry beyond that? Or is there an issue with publishing many tables?
If we want to publish just a single thing, can we publish a DataGroup? or maybe we have no schema for a DataGroup on kafka?
There was a problem hiding this comment.
Per-component tables caused complications when wiring back into the workflows. I did this on an earlier version of the branch. It also complicated plotting of the tables.
Indeed, we do not have a schema fro DataGroup.
There was a problem hiding this comment.
Per-component tables caused complications when wiring back into the workflows.
Can you elaborate a little on what were the problems?
There was a problem hiding this comment.
Three things, all of which the branch went through before the block layout.
Wiring it into LOKI's I(Q). Two mechanisms meet badly there, so a bit of setup:
- When you start an I(Q) job you pick, from dropdowns, which physical monitor acts as the incident monitor and which as the transmission monitor -- LOKI has four candidates. These are "aux selections": the job's own source is a detector bank, and the monitors are auxiliary inputs chosen per job.
- A streamed context input is declared the other way round: a
ContextBindingsays, at import time, "this workflow's key X is filled by the stream named Y", and a job with such a binding waits (pending_context) until that stream arrives. It is a property of the workflow spec, fixed before any job exists.
With one table per component, the stream a job needs -- wavelength_lut/loki_monitor_1 versus ..._2 -- depends on a choice made at job creation, which the import-time declaration cannot know. Three shapes were tried and committed in turn:
- Bind the two default monitors and raise at job creation if the operator picked others. That makes the dropdown a lie in wavelength mode.
- Bind all four candidates, each to its own key, and let the factory (which does see the selection) map the chosen two onto the incident/transmission roles. The two unselected keys become parameters the workflow stores and never computes from, and the candidate list ends up restated in the factory.
- Let a binding's stream name carry a placeholder --
wavelength_lut/{incident_monitor}-- rendered against the job's actual selection when the gate is resolved. This works, but the name is now declared-but-unrendered in one place and rendered in another, and the Kafka subscriptions (derived at startup, before any job exists) have to be expanded over every declared choice so they stay a superset of whatever a job might render. It was the most intricate mechanism in the PR.
One shared monitor table removes the question instead of answering it: there is a single stream, one provider generic in MonitorType that sciline instantiates per role, and each role picks its rows by the flight path its own graph already computes. Which monitor fills a role is settled by geometry the job already holds, not by the name of the stream it reads. Stream names go back to being fixed at declaration time, which is what the statically derived subscriptions want anyway.
Plotting. With one table per component, the tables appear as N outputs of the LUT workflow. The plot wizard lets you multi-select sources in one step, but pick only one output (a radio button in step 1) -- so comparing components meant adding one layer per table, three wizard steps each, and LOKI's output dropdown was fourteen entries deep with an arbitrary one auto-selected. That asymmetry isn't an oversight: every source of a workflow shares the same output template, so sources are homogeneous by construction and safe to overlay, whereas outputs are different quantities and nothing guarantees they combine. Making outputs multi-selectable would mean building that missing check (intersecting compatible plotters and windowing options, deciding what a mixed-output table column means, and fixing legend labelling, which today counts outputs per cell and would silently collapse every curve to one label). So I built the other fix instead -- #1249, where a result carries the component it is about, putting the tables back on the source axis. It worked; I closed it because the block layout made it unnecessary.
Bookkeeping. The workflow's outputs model had to be generated per instrument with pydantic.create_model (the last such case in the codebase), component names doubled as output field names, and every component cost a stream, a binding and a sciline key.
Underneath all three: a lookup table is a function of distance and event_time_offset alone and carries no component identity, so a per-component table is only a restriction of the same function. The split bought nothing in content -- which is what made it worth paying the block-selection cost to remove. ADR 0011's alternatives table has the short version.
ADR 0010 had grown to 434 lines and 5600 words, more than twice the next-longest record, and read as three documents in one: a decision record, a mechanism explanation that the module docstrings of lut_blocks, lut_ranges, lut_context and context_outputs now carry nearly verbatim, and a history of the shapes the branch tried and reverted. The index also asks for one decision per ADR, and this one held two. ADR 0010 now records the reusable decision: a workflow output republished as a context input stream, requested by Sciline key and gated from the built graph, with the per-job parameter-dependent gate that amends ADR 0003. Its standing limitations are stated generically, since no liveness and no replay apply to any context output, not just the lookup table. ADR 0011 records the lookup-table layout: two tables in blocks, ranges derived from the geometry artifact and padded for motion, block selection by flight path. Mechanism detail points at the module that owns it instead of repeating it, and every rejected option is argued once, in the alternatives table. The identity-coord clearing is not implemented on this branch. It stays as a deferred paragraph pointing at issue 1248, which already carries the design, and its rejected alternatives stay in the table because the issue does not record them. Two test docstrings still described superseded shapes (separate specs per coordinate mode, a per-spec ContextBinding for the table). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
| The LUT workflow keeps its single `chopper_cascade` source and publishes two outputs, a | ||
| detector table and a monitor table, as context streams. The range parameter is removed. | ||
|
|
||
| Two, rather than one per component, because a table is a function of `distance` and |
There was a problem hiding this comment.
I'm still slightly confused: are we publishing only one table or two tables? Which one was it you were showing us screenshots of?
There was a problem hiding this comment.
Which one was it you were showing us screenshots of?
This morning? That was the released/old version which caries one big table covering the whole range (as configured). On this branch there are two tables, one for detectors (typically small range, but densely covered range), one for monitors (sparse, with some quirks around padding bins etc. that you probably saw).
There was a problem hiding this comment.
The table for monitors can span many meters (some monitors are close to the source, others close to the sample).
Is it really worth splitting away the table for the detectors? Can't we have one single table for everything with dense regions around the components and sparse regions in-between?
In addition, we made the lookup in numba fast by enforcing a regular grid. If I understood correctly, you find a regular chunk in the table, and then use the number interpolator on that chunk.
How fast/slow is the part that searches for the correct chunk?
There was a problem hiding this comment.
Good questions.
On splitting. Dense regions with nothing in between is what both tables already are — the gaps carry no rows at all, not even sparse ones. That part is forced by size: BIFROST's monitors span 0.5–155 m, which as one uniform grid at the default 0.1 m resolution is 1549 rows × 286 event_time_offset bins = 3.4 MB to carry 8 rows of information.
So the only open question is whether the detector blocks and the monitor blocks ride in one message or two. Measured from the geometry artifacts at default resolution (2.2 kB/row):
| detector block | monitor blocks | |
|---|---|---|
| DREAM | 77.5–79.5 m, 20 rows, 45 kB | 2 monitors, 4 rows, 9 kB |
| LOKI | 24.8–43.9 m, 191 rows, 427 kB | 4 monitors, 10 rows, 22 kB |
| TBL | 30.8–32.1 m, 13 rows, 29 kB | 1 monitor, 2 rows, 4 kB |
| BIFROST | (no placeable detector yet) | 4 monitors over 0.5–155 m, 8 rows, 18 kB |
Two reasons to keep them apart. The detector block is the only large one, and the one that grows with the resolution. Merging hands it to every consumer that wants a monitor: a monitor view job, and both monitor roles of an I(Q) job, each receiving it on every chopper change to read two or three rows.
The other is plotting, which is the more visible one day to day. The two tables need different plotters: the detector table is a single dense uniform block, so it renders as an image; the monitor table is a handful of rows at each monitor with tens of metres of nothing between, so its distance axis is not uniform and it is readable only as "Overlay 1D", one curve per row. Merged, no plotter setting shows both — as an image LOKI's ten monitor rows are invisible slivers on a 37 m axis, as an overlay it is 201 curves. Two outputs means picking one in the wizard and getting a readable picture; one output means building a block selector into the plotting layer to get the same thing back.
Those are the defaults, which are also essreduce's default_parameters() and what DREAM's shipped tables actually use (754 × 287 at 0.1 m / 250 µs). The argument doesn't depend on them: payload scales as 1/(distance_resolution × time_resolution), and the two axes work in our favour — the instrument with the long detector span is the one needing the least resolution (LOKI, 19 m, SANS), while the one that would want a finer grid has a 2 m span (DREAM is still 445 kB at ten times the distance resolution). Finer resolution makes merging worse, never better.
The split itself costs one output field and one stream name; the block machinery is identical either way. I've replaced the ADR's "megabyte-scale" with the measured numbers and the plotting reason.
On the chunk search. It is not in the lookup path. Block selection is a provider taking the context key, so StreamProcessor runs it once per set_context — once per arriving table, i.e. per chopper change — and caches the result for every chunk after that. What reaches WavelengthInterpolator is one uniform block, exactly as a file-based table was; the regular-grid assumption is untouched. That is precisely why select_block exists: handing it the concatenation would read wrong rows silently under numba (and correctly under the scipy fallback).
Cost: 58 µs for LOKI's detector table (one block), 112 µs for BIFROST's monitors (four), 381 µs for an invented 20-block/2000-row table. Most of that is the nanmin/nanmax over the job's Ltotal rather than the block scan — 1.1 ms if Ltotal has 1.5M pixels. For scale, essreduce builds a fresh WavelengthInterpolator on every chunk (44 µs for LOKI's block), so the once-per-table selection is worth about one chunk's interpolator construction.
The alternatives table dismissed a merged table as "megabyte-scale", which overstates it at the default resolution: LOKI's detector block is 427 kB against 22 kB of monitor rows. State the measured sizes instead, and add the reason the row did not give at all -- the two tables want different plotters, since a dense uniform block renders as an image and a few rows per monitor only as one curve per row. Block selection sits in a context-fed provider, so it runs once per arriving table rather than per chunk, and the interpolator still sees a uniform grid. Say so where the block layout is documented.
nvaytet
left a comment
There was a problem hiding this comment.
I don't have any more questions.
I did not look at the tests, nor did I check all the code (it was too much to process).
I tried to think about the more general implications.
Thanks, that was exactly what I was after. |
Feeds the wavelength lookup table computed by
wavelength_lut_workflowback into the backend workflows that need it, replacing the tables loaded from files at import time. Those files describe a nominal chopper configuration, so running in any other configuration is silently wrong; the LUT workflow already computes the right table from the live chopper cascade, but publishes it as a result nothing consumes. This PR closes that loop for DREAM and LOKI.The new concept is a workflow output republished as an input stream for other workflows. The NICOS derived-device mirror (ADR 0006) is publish-only and nothing consumes a mirrored topic, so this is the first cross-service feedback edge. ADR 0010 records the design; the commits implement it end to end.
What it does
The LUT workflow keeps its single
chopper_cascadesource and publishes two tables — one for the detectors, one for the monitors — declared as context outputs on a dedicated topic. Consumers ask for their group's table by inserting one provider, and select their own rows from it.Two rather than one per component, because a table is a function of
distanceandevent_time_offsetalone and carries no component identity: a per-component table is merely a restriction of the same function. What varies across components is only which stretch of beamline must be covered, so components that share a stretch share a table with nothing lost. Two rather than one overall, because a monitor job has no use for the detectors' dense rows, which are the large payload.The user-facing distance-range parameter is gone. It defaulted to a span covering no instrument correctly, so an operator starting the workflow with defaults could silently blank every detector: a lookup outside the table's range yields
NaN, those events fall outside every histogram bin, and the component renders empty with no error anywhere.Decisions worth pulling out
A table carries rows only where components are. Neither table is a single uniform grid — monitors sit tens to hundreds of metres upstream of the detectors — so a table is a concatenation of uniform blocks: one dense block across the detectors, one per monitor. LOKI's four monitors cost twenty-six rows in total. Empty rows are not free: BIFROST's 155 m span at a 0.1 m resolution is 1550 rows of 286 event-time-offset bins, a 3.5 MB message against a broker's 1 MB default, and 35 MB at 0.01 m, to carry what fits in tens of kilobytes.
The concatenation is deliberately not uniform, and essreduce's
interpolator_numbaassumes uniformity — it locates a row by dividing, reading the wrong row silently, and only under numba, since the scipy fallback handles an uneven axis correctly. A consumer therefore selects its own block before the table reaches essreduce, matching theLtotalits graph already computes against the distances the table already carries. Component identity stays off the wire entirely. The boundary between blocks is stated, in ablockcoord, rather than inferred from where the row spacing jumps: inferring it is a threshold on a float difference, tuned against the padding the table builder happens to add, and it would forbid the producer from ever emitting two blocks that overlap or abut, which two nearby monitors otherwise would.Ranges are derived, not declared. Each component's range runs essreduce's own
DetectorLtotalandMonitorLtotalproviders — the very ones the consumer runs at lookup time — so the range and the lookup agree by construction rather than by review. An earlier draft of the ADR had theLtotalrule declared per component, justified by indirect geometry putting a table "tens of metres" from where it is queried. That magnitude was wrong: the secondary flight path is metres against primary paths of tens to hundreds of metres, so padding absorbs it and the declaration buys exactness the table's distance resolution does not reward. Over-padding costs recompute in the LUT job and nothing else; under-padding is silent.Motion is declared as an axis range, not a displacement. The geometry artifact stores a live f144-driven transform as an empty NXlog, so the component riding it has no position at all until something supplies an axis value. Instruments declare one
AxisRangeper moving axis, keyed by NeXus transform path. Both bounds are axis values in the axis's own units, so the transform keeps supplying the direction and sense of the motion: the range comes from evaluating the geometry at the bounds, not from assuming which way along the beam the axis travels. Which components ride an axis stays derived, since a component is affected precisely when the axis appears in itsdepends_onchain. LOKI's carriage runs from 0 to 15 m, placing the rear bank at 28.5 .. 43.9 m. Translations are bracketed exactly where it matters — pixel positions are affine in the value, soLtotalis convex and its maximum sits at a corner of the box the bounds span, and padding absorbs the interior minimum a component crossing the sample plane could have. An angle has no such property, so a live rotation axis is refused rather than approximated and its component falls into the no-table case below.A component that cannot be placed gets no block.
beam_monitor_m4rides its own undeclared axis, so the LUT workflow cannot place it and lays out no rows for it. A group with no placeable component at all publishes no table, so its stream is never declared and a job asking for it fails at creation rather than waiting forever. Where the group is fine but one component is not, a job on that source gates like any other and then fails at its first recompute, reporting its flight path against the table's coverage. An I(Q) aux selection that picks such a monitor is rejected at job creation instead, with an error naming the monitor: there the selector is the thing at fault, and the job's own source may well be placeable.Coordinate mode stays a parameter on one workflow. Gating every job of such a spec on the table would make time-of-arrival — the mode you fall back to when everything else is broken — depend on an operator-started job that re-emits only on chopper change. ADR 0003 prescribed splitting the spec for exactly this case. That was implemented here and then reverted: the dashboard data plane is keyed by
(workflow_id, source_name, output_name), so two specs give the two modes different output identities, a plot cannot follow a mode switch, the operator runs two jobs, and every per-instrument params override is duplicated. Instead the gate is derived from the graph the params build, so nothing states the condition at all: the factory inserts the reassembly provider unconditionally, a TOA job's graph reduces straight fromevent_time_offsetand leaves that provider on a branch no target reaches, and the job therefore requests nothing and gates on nothing. This supersedes ADR 0003's param-dependent-context non-goal, which was a YAGNI call made when the only over-gated stream was an always-on control-system PV.Both of a reduction's monitor roles read the one monitor table. I(Q) needs a table per sciline
Component— the detector plus the incident and transmission monitor roles — and which physical monitor fills a role is a per-job aux selection. Sharing the monitor table dissolves the problem rather than solving it: one stream, and one provider generic inMonitorTypethat sciline instantiates per role, each instance selecting its rows by that role's ownMonitorLtotal. Which monitor fills a role is settled by geometry the job already holds, not by the stream it reads.This supersedes three earlier shapes: binding the default monitors and raising when the selection differed, which made the aux selector a lie in wavelength mode; binding every candidate monitor to its own synthesized key, leaving the unselected ones dead parameters; and aux-templated stream names (
wavelength_lut/{incident_monitor}), where the binding named the aux field and gate resolution rendered it against the job's selection. That last mechanism worked and was the most intricate thing in the PR — declared-versus-resolved names, route derivation expanding a template over an aux field's declared choices to keep subscriptions a superset. It has no user left and is removed, on both sides of the mirror: stream names are plain and fixed at declaration time again, which is what the statically derived Kafka subscriptions and ADR 0006's no-job-identity rule both want.The gate is derived, not declared
Whether a job reads the table used to be stated twice: the factory inserted the reassembly provider, and the instrument declared a
ContextBindingnaming the specs, sources and params that gate on it. Two statements of one fact, which can disagree — and the silent direction of the disagreement leaves a job inpending_contextforever, waiting for a stream it would never read. The declaration form also could not express a spec reading another group's table without contortion: LOKI's I(Q) had to bind the monitor table against its detector sources, because the monitors arrive as an aux selection and are not the jobs' source names.The graph already knows. An instrument now only offers a stream under the workflow key it fills (
Instrument.offer_context_stream), naming no spec, source or params, andStreamProcessorWorkflow.buildkeeps a declaration when its key is an ancestor of a target key — the exact condition under whichfinalizewould fail without it. Reachability rather than insertion is what makes this work for a params-dependent mode: a provider the targets cannot reach is pruned, so the factory inserts unconditionally and the params decide. Job creation then reads the result back off the workflow, so the order becomes validate → build → gate; nothing consumed the gating set before the build, so the reordering costs nothing.The two mechanisms divide cleanly, but not along push-versus-pull, which is where an earlier draft of this PR drew the line. An offer is derivable whenever the graph names the key, so a binding is required in three cases only: the key reaches the graph solely because the binding injects it (a chain patch is never mentioned by the graph, so there is no request to read back), the stream filling a key varies per source (offers are bijective instrument-wide), or the stream is private to one spec. That last one is a routing constraint rather than a semantic one: route derivation runs at startup with no params while an offer is taken up per job from the built graph, so every service hosting any spec must subscribe to every offered stream. The wavelength LUT's own per-chopper setpoints stay bindings for that reason — they are consumed by one workflow on the timeseries service, and offering them would add twelve dead subscriptions each to
data_reduction,detector_dataandmonitor_dataon BIFROST.Motion is therefore not automatically on the binding side. BIFROST's tank and sample rotations are ordinary parameters of the cut workflow, so they are offered too, and the two
skip_instrument_contexts()calls that used to restate graph reachability by hand — the detector view sums over banks, the ratemeter is counts-only — are gone with them. Route derivation gathers offered stream names alongside binding names, which it did not need to do while every offer was published on the context topic and dropped again; without it, moving BIFROST silently unsubscribeddata_reductionfrom the motion topic. An offered name is also checked at registration against what anything actually publishes, the check bindings already had: a gate on a name nobody publishes never opens.ContextBinding.predicateloses its only user and is removed, along with the two-method split that existed only to filter by it. Params validation still moves up into job creation andWorkflowFactory.createstill takes the validated model, so there is one validation site rather than two that can drift.The stream-name-to-key mapping is declared exactly once either way: consuming factories contribute only the reassembly provider. The registration-time conflict check surfaced that
Instrument.load_factorieswas not idempotent — repeated loading appended duplicate bindings whose synthesized chopper-setpoint keys are fresh objects per run — so it now no-ops on a loaded instrument.Unrelated but adjacent:
scripts/visualize_workflows.pycalledWorkflowFactory.createwithoutparamsand so failed to create every workflow it tried to render, swallowing theTypeErrorper workflow. Fixed here since the same call site needed the new argument.Scope
DREAM and LOKI migrate; they are the only instruments offering wavelength mode today. The detector- and monitor-view factories lose their lookup-table filename argument outright rather than defaulting it, so a view has no file path left to fall back to, and LOKI's I(Q) reduction migrates with them. This also removes a pooch download from both instruments' startup path. What remains of
LookupTableFilenameis set explicitly by the unmigrated BIFROST and ESTIA reduction pipelines at their own call sites — an input, not a fallback.Wavelength mode is also no longer offered where it cannot work. Every logical view on every instrument previously offered it in the UI and raised at job start, since they run on
InstrumentDetectorSource, which carries no geometry. Those specs now use a time-of-arrival-only params model, and a test pins that any spec accepting wavelength can actually build a workflow in that mode.Known gaps
beam_monitor_m4has no wavelength mode until its axis range is declared. The identity coord and consumer clearing described in ADR 0010 are not implemented yet (#1248), so a new table does not currently reset accumulated statistics. The standing limitations the ADR records — nothing guarantees the LUT job is running, a backend restart loses the table, and the gate protects startup rather than steady state — all still apply.Test plan
JobFactory, gating on both tables whichever monitors it selects, its monitor roles selecting their blocks by flight path, and a selection whose monitor has no block being rejecteddetector_projectionjobs created in both modes: wavelength gates onwavelength_lut/detectors, TOA gates on nothing, ROI aux routed to the job in both and gating in neither