Add StreamingActiveLearner: learner loop driven by streamed data - #98
Add StreamingActiveLearner: learner loop driven by streamed data#98andre-merzky wants to merge 6 commits into
Conversation
Digital-twin architectures need a learner that is retriggered by streamed input (sensor windows, events) rather than one that drives itself with an internal iteration count and its own simulation task. The existing learners run once to convergence; embedding them in a DT runtime means data arriving after startup never influences learning. StreamingActiveLearner turns the loop data-driven: items arrive via feed() or attached async-iterator sources (pumps deferred to start() so construction needs no event loop), a window of batch_size items (or a max_wait flush, with optional latest-wins conflation) triggers one training -> active-learning -> criterion iteration, and the window is passed to the training task with the previous active-learn result as a warm-start dependency. The stop criterion becomes a publish gate: on_model_ready callbacks fire when it is met, but consumption continues until stop() or source exhaustion — a learner that keeps improving its model as the stream evolves. Verified: 7 unit tests (windowing, publish gate, stop, max_wait flush, conflation, ctor sources without event loop, validation); full unit suite 126 green. Benchmarked at 88k-349k msg/s machinery throughput (in-process feed, batch 1-100). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The repo's pre-commit hook (docformatter, wrap at 100) re-wraps docstring paragraphs; run it locally so CI proceeds to the test jobs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds a new StreamingActiveLearner to support data-driven (stream-ingested) active learning loops suitable for digital-twin runtimes, where learning iterations are triggered by arriving data windows rather than internal iteration scheduling.
Changes:
- Introduces
StreamingActiveLearnerwith async ingestion (feed/ async-iterator sources), windowing (batch_size,max_wait), optional conflation, and “publish gate” criterion semantics viaon_model_ready. - Adds unit tests covering windowing, source exhaustion,
stop()behavior,max_waitpartial flush, conflation, ctorsourcesbehavior, and missing-task validation. - Exposes
StreamingActiveLearnerfromrose.alvia__init__.py.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
rose/al/streaming_learner.py |
Implements the streamed, window-triggered active learner loop and publish-gate callbacks. |
tests/unit/test_streaming_learner.py |
Adds unit coverage for streaming windowing, stop/exhaustion, publish-gate semantics, and conflation behavior. |
rose/al/__init__.py |
Exports StreamingActiveLearner as part of the public rose.al API. |
Suppressed comments (1)
rose/al/streaming_learner.py:257
- Pump tasks are cancelled in the
finallyblock, but they aren’t awaited. This can leave pending tasks running briefly afterstart()exits and can produce "Task was destroyed but it is pending!" warnings when the event loop closes. Consider awaiting the cancelled tasks withasyncio.gather(..., return_exceptions=True)and clearing_sourcesto ensure a clean shutdown.
task.cancel()
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| super().__init__(asyncflow, register_and_submit=True) | ||
| self.batch_size = batch_size | ||
| self.max_wait = max_wait | ||
| self.conflate = conflate |
There was a problem hiding this comment.
Fixed in 64330d9 — batch_size and max_wait are validated in init and fail fast with ValueError; covered by test_invalid_params_raise.
| async def feed(self, item: Any) -> None: | ||
| """Feed a single data item into the learner's stream.""" | ||
| await self._queue.put(item) | ||
|
|
||
| def attach_source(self, source: AsyncIterator[Any]) -> None: | ||
| """Attach an async iterator as a data source. | ||
|
|
||
| Sources attached before :meth:`start` are only consumed once the | ||
| learner loop runs. The loop ends once all attached sources are | ||
| exhausted and the queue is drained; learners fed only via | ||
| :meth:`feed` run until :meth:`stop` is called. | ||
| """ | ||
| self._open_sources += 1 | ||
| if self._started: | ||
| self._start_pump(source) | ||
| else: | ||
| self._pending_sources.append(source) | ||
|
|
||
| def _start_pump(self, source: AsyncIterator[Any]) -> None: | ||
| async def pump() -> None: | ||
| try: | ||
| async for item in source: | ||
| await self._queue.put(item) | ||
| finally: | ||
| await self._queue.put(self._END) |
There was a problem hiding this comment.
Fixed in 64330d9 — conflation now happens at ingestion time: feed() and the source pumps drop the oldest backlog before enqueuing, so the queue stays bounded to ~batch_size items during slow iterations (sentinels are processed, not lost). Covered by test_conflate_bounds_queue_at_ingestion.
…stion batch_size < 1 and non-positive max_wait now fail fast with a clear ValueError instead of producing empty windows or an asyncio timeout error at runtime. Conflation moves to ingestion time (feed and source pumps drop the oldest backlog before enqueuing), so the queue stays bounded to ~batch_size items even while a slow iteration runs, matching the documented latest-wins behavior. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
BenCarter44
left a comment
There was a problem hiding this comment.
Nice! Looks good. I wrote a small example located in examples/active_learn/streaming, and it is working nicely. I like simplicity of the streaming learner.
I have three comments, one small thing missing and two design questions:
-
It doesn't look like simulations are triggered in this. Shouldn't simulations be part of the loop (as active learning's role is to request more sims)
-
Right now, the StreamingActiveLearner requires the criterion function in order to run on_model_ready(). This is ok, but more as a design decision: should the StreamingLearner simply be SequentialActiveLearner + streams (what it essentially is right now), or should it support any generic Learner and then add streams? I do think it is fine leaving it the way it is; just more took notice of it. (A generic learner would pose its own complexities)
-
Right now, the on_model_ready() callback is only able to capture state of the training if the training tasks are function_tasks and emit dictionaries. Design decision: if a user is using executable tasks, should they be required to build out their own implementation for model reference retrieval?
|
|
||
| self.clear_state() | ||
|
|
||
| train_cfg = self._get_iteration_task_config( |
There was a problem hiding this comment.
Should it also trigger the simulation function too? (as the active learner selects new data that it wants to rerun sims on)
| stop_result = await self._register_task(crit_cfg) | ||
| if self.is_stopped: | ||
| _stop_reason = "stopped" | ||
| break |
There was a problem hiding this comment.
If a criterion function is not set, the model update callback won't be triggered. More from the design perspective: should we require a StreamingLearner to always have a criterion function set?
(Right now its closely matching SequentialActiveLearner's behavior, which is ok, but I wonder if the StreamingLearner should be more broad and support custom pipelines).
| _stop_reason = "stopped" | ||
| break | ||
| self._extract_state_from_result(train_result) | ||
| self._extract_state_from_result(acl_result) |
There was a problem hiding this comment.
I see that it stores the state in the result from the acl and train. That is good. However, this won't work for executable tasks (and therefore the on_model_ready callback won't get any metadata)
Or: it assumes that if the user is using executable tasks, they add their own logic to pass a reference from the trainer to the callback.
|
Also, I'm unsure what you mean / where you are referring to in the original post:
|
|
@andre-merzky @BenCarter44 thanks for the great work, a few things to mention: Conceptually, I agree with the streaming behavior, but I would not introduce We should keep the existing ROSE learners and make streaming a general behavior/capability that can be applied to them: and: This gives us the DT-driven, I would like to keep the streaming approach consistent with the ROSE design for now. The current learners are essentially batch learners, but the learner abstraction is extensible. Similarly, streaming should be introduced as a general, reusable behavior/capability rather than as a separate learner hierarchy. This allows SequentialActiveLearner and ParallelActiveLearner to support streaming while preserving the existing ROSE design. Happy to expand further/help if needed. |
|
Thanks @AymenFJA , that makes sense. I'll rework the PR! |
Summary
Digital-twin architectures need a learner that is retriggered by streamed input (sensor windows, events) rather than one that drives itself with an internal iteration count and its own simulation task. The existing learners run once to convergence — embedded in a DT runtime, data arriving after startup never influences learning.
StreamingActiveLearnermakes the loop data-driven:await learner.feed(item)or attached async-iterator sources (single or list, also via thesourcesctor arg; pump tasks are deferred tostart(), so construction needs no running event loop).batch_sizeitems form a window;max_waitflushes a partial window after a quiet period;conflate=Truedrops backlog and keeps the newest items when iterations are slower than the stream (latest-wins).state.should_stopmarks the model as publishable andon_model_readycallbacks fire (mapping 1:1 onto a DT runtime'spublish_new_model), while consumption continues untilstop()or source exhaustion.IterationStatestreaming,LearnerConfig/set_next_config, state registry, and trackers all work as in the existing learners.A DT-side integration demo (sensor → ZMQ broker → investigator feeding this learner, republishing on criterion-met) lives in the digitaltwin repo under
test/rose_streaming/.Verification
stop()on an empty queue,max_waitpartial flush, conflation, ctor-sources without an event loop, missing-task validation. Full unit suite: 126 passed.feed()vs ZMQ pub/sub through the DT broker, with stubbed tasks (machinery only) and real no-op tasks through asyncflow + process pool (plots below):The end-to-end numbers use radical-cybertools/radical.asyncflow#91 (event-driven scheduler); on asyncflow 0.5.0 the per-window floor is ~22ms (10ms poll interval × 2 chained tasks), capping batch=1 at ~45 msg/s.
Plots
With event-driven asyncflow scheduler (radical-cybertools/radical.asyncflow#91):
Baseline on released asyncflow 0.5.0:
🤖 Generated with Claude Code