Add a general discrete-event queue - #632
Conversation
What this builds, and what it deliberately does notThe mechanism already existed; only the payload was missing
VerificationBit-identity for event-free runs, captured on
Expressing the same introductions through the new events path is also The fifth accumulator is pulse-only on purpose. odelia's controller takes its error norm over every state component, so an accumulator with a non-zero rate would join the step-size decision and could move TF24. Held at rate zero it contributes exactly zero. That is why no Three things found along the way
Two things a reviewer should not read as bugs
Where this departs from #628
Not in scopeRuntime insertion (designed for, unexposed — #601 needs it for stochastic deaths); state-triggered events, which need rootfinding odelia does not have; the stochastic tower, which still refuses any event but an introduction; and disturbance as a patch-clearing event — Two hazards to weigh before the next step
Both are items from #608, which is still open and unmerged — its design doc lives only on Build noteplant |
Follow-up: the generic layer now reads generically@dfalster pointed out a layering error in the first pass: the queue,
The pulse now rides an abstraction plant already had. Environments declare Model-specific names stay where they are accurate. One consolidation. Verification unchangedRe-ran the whole ladder after the rename: FF16, K93 and TF24 still |
Working plan for #522. Records the decisions taken before implementation: one queue with node introduction migrated onto it, events as a run_scm() argument rather than a Parameters field, and five crude action types. The load-bearing idea is that "instantaneous" means instantaneous to the outer solver only -- an action may sub-integrate its own fast model over a nominal duration with demography frozen, which is how heatwaves fit without widening the solver contract. Refs #522, #601. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every event in the queue was a node introduction, with a bare species index for a payload. Add an EventType tag and a params vector so rainfall pulses, harvest and the rest can share the one queue and the one stop/apply/resume loop, and route non-introduction events through Patch::apply_event(). No event type but NodeIntroduction is constructible yet, so nothing moves: FF16, K93 and TF24 are identical() on ODE step times, fitness outputs and final state. Fixes latent UB in r_set_max_time(), which read events.back() on an empty list -- the normal case, since max_time is set before any times are added. Refs #522. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds the Events wire format -- (time, type, species_index, params) as plain data -- and threads it through the SCM constructor, so a run's discrete events are supplied alongside its parameters and environment rather than buried in the schedule. Node introductions are expressible as events too. Events is a wire format only; the schedule stays the source of truth, and scm$events reads it back so a refined schedule round-trips. An empty list means "none supplied" and falls back to node_schedule_times, so every existing run takes exactly the path it did before: FF16, K93 and TF24 stay identical(), and the same introductions expressed as events give an identical run. Refs #522. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The first real action: a depth of water delivered to the soil surface at an instant, capped at what the surface layer can hold. The cap is the substance of it -- a jump is applied between solver legs, so no error estimate and no step rejection stand behind it, and a realistic dryland event already exceeds a moderately wet layer's free capacity. The excess is recorded as runoff. The fifth accumulator is fed only by pulses and held at rate zero, so it stays out of the step-size error norm: TF24 with no pulses keeps identical step times, fitness and state, with one extra slot holding zero. Refs #628, #522. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Each event now says what it acts on -- the environment, the whole patch, or one species -- validated against what its type can accept. No per-cohort target: a cohort has no stable address across a run, so selecting cohorts is a height band in the action's parameters instead, which is what thinning actually needs. Adds thinning and heat damage over one shared primitive, since they differ only in how the per-node survival fraction is chosen. Heat damage sub-integrates its own damage model over a nominal duration with demography frozen; to the solver that is still one instantaneous jump. Applied events are recorded and readable as scm$event_log. What was asked and what was done differ routinely -- a pulse is capped at what the soil can hold -- and that difference was previously only inferable from an accumulator. Refs #628, #627, #522. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds events_default(p) -- the schedule a run gets when no events are supplied -- so adding one event to an otherwise ordinary run does not mean rebuilding its schedule by hand. events() now also accepts whole Events objects, which is what makes that compose. NEWS entry covering the interface, the event log, and the two things worth knowing: an event is a stop time, so a run with events legitimately differs from one without; and "instantaneous" binds the solver, not the action. Refs #628, #522. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The queue, the patch and the environment base are shared by every strategy and environment, so their vocabulary should not assume plants, water or heat: RainfallPulse -> ResourcePulse, HeatDamage -> ClimateExtreme, Thinning -> Harvest, and a removal selects on size rather than height. The pulse now rides an abstraction plant already had. Environments declare n_resources(); a pulse names one through target_index, exactly as a species event names a species, so Environment gains add_resource_pulse() and TF24 implements it over its soil layers. Model-specific names stay, where they are accurate: rainfall_pulse() and TF24_Environment::add_water_pulse() are the same action under the name that reads correctly there. Refs #628, #627, #522. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
c802fce to
52852b3
Compare
Rebased onto #633, and the evidence re-earned rather than re-asserted#633 moved TF24 by +0.36%, so every number in this PR's earlier comments was measured against dependencies that no longer exist. Re-ran the whole ladder against the new The claim is unchanged, because it was never a claim about absolute values — it is that this change moves nothing:
TF24 is 1065 steps now, not the 1055 quoted earlier — that difference is #633's, not this PR's. Expressing the same node introductions through the events path is still Rebase mechanics. Only one file conflicted, |
Adopts odelia 0.3.1's opt-in domain checks. A non-finite environment state and an infeasible leaf probe both used to kill a run outright; both are now handed to the stepper to shrink and retry. If the minimum step still cannot escape, odelia stops and reports the original message, so nothing is lost. phylloptim's infeasible_error is a sibling of odelia's DomainError, not a subclass, so the stepper cannot see it -- solve_leaf() translates it. Only that one type, so a bug stays a bug rather than becoming step-shrinking. A runaway cohort density stays fatal: that divergence is in the equations, not the stepper. And ode_state_valid() checks only the environment block, because a node's log_density is legitimately -Inf. Inert where nothing is wrong: FF16, K93 and TF24 stay identical(). Refs #628, #608, #599. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adopted odelia's domain checks — and a finding about #599What changedTwo conditions that used to kill a run outright are now rejected steps:
Two things deliberately left alone:
#599 no longer reproduces — so this is insurance, not a demonstrated fixI ran #599's own recipe (
So the hooks did not fix #599 — something already had, before them. I am not claiming otherwise, and #599 looks closeable; worth someone confirming across the full 40 seeds and on Windows, since the issue notes it was a platform lottery near the boundary. That leaves these hooks as insurance against a hazard that is real but not currently biting: #608's step-size inheritance is untouched, and every event is a leg boundary, so pulses create far more opportunities for it than introductions ever did. Note the scope limit: VerificationInert where nothing is wrong — FF16, K93 and TF24 still The new test pins the contract directly rather than relying on a failure to provoke it: a sane state is accepted, |
|
Hi @yangsophieee @itowers1 @elijahmagistrado Here's the event structure we discussed. Feedback, welcome. Also, for general guidance on GitHub workflows, feature branches and reviewing PRs, see new page on overstorey https://traitecoevo.github.io/overstorey/contributing/how-we-work.html |
Working plan for #522. Records the decisions taken before implementation: one queue with node introduction migrated onto it, events as a run_scm() argument rather than a Parameters field, and five crude action types. The load-bearing idea is that "instantaneous" means instantaneous to the outer solver only -- an action may sub-integrate its own fast model over a nominal duration with demography frozen, which is how heatwaves fit without widening the solver contract. Refs #522, #601. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every event in the queue was a node introduction, with a bare species index for a payload. Add an EventType tag and a params vector so rainfall pulses, harvest and the rest can share the one queue and the one stop/apply/resume loop, and route non-introduction events through Patch::apply_event(). No event type but NodeIntroduction is constructible yet, so nothing moves: FF16, K93 and TF24 are identical() on ODE step times, fitness outputs and final state. Fixes latent UB in r_set_max_time(), which read events.back() on an empty list -- the normal case, since max_time is set before any times are added. Refs #522. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds the Events wire format -- (time, type, species_index, params) as plain data -- and threads it through the SCM constructor, so a run's discrete events are supplied alongside its parameters and environment rather than buried in the schedule. Node introductions are expressible as events too. Events is a wire format only; the schedule stays the source of truth, and scm$events reads it back so a refined schedule round-trips. An empty list means "none supplied" and falls back to node_schedule_times, so every existing run takes exactly the path it did before: FF16, K93 and TF24 stay identical(), and the same introductions expressed as events give an identical run. Refs #522. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The first real action: a depth of water delivered to the soil surface at an instant, capped at what the surface layer can hold. The cap is the substance of it -- a jump is applied between solver legs, so no error estimate and no step rejection stand behind it, and a realistic dryland event already exceeds a moderately wet layer's free capacity. The excess is recorded as runoff. The fifth accumulator is fed only by pulses and held at rate zero, so it stays out of the step-size error norm: TF24 with no pulses keeps identical step times, fitness and state, with one extra slot holding zero. Refs #628, #522. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Each event now says what it acts on -- the environment, the whole patch, or one species -- validated against what its type can accept. No per-cohort target: a cohort has no stable address across a run, so selecting cohorts is a height band in the action's parameters instead, which is what thinning actually needs. Adds thinning and heat damage over one shared primitive, since they differ only in how the per-node survival fraction is chosen. Heat damage sub-integrates its own damage model over a nominal duration with demography frozen; to the solver that is still one instantaneous jump. Applied events are recorded and readable as scm$event_log. What was asked and what was done differ routinely -- a pulse is capped at what the soil can hold -- and that difference was previously only inferable from an accumulator. Refs #628, #627, #522. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds events_default(p) -- the schedule a run gets when no events are supplied -- so adding one event to an otherwise ordinary run does not mean rebuilding its schedule by hand. events() now also accepts whole Events objects, which is what makes that compose. NEWS entry covering the interface, the event log, and the two things worth knowing: an event is a stop time, so a run with events legitimately differs from one without; and "instantaneous" binds the solver, not the action. Refs #628, #522. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The queue, the patch and the environment base are shared by every strategy and environment, so their vocabulary should not assume plants, water or heat: RainfallPulse -> ResourcePulse, HeatDamage -> ClimateExtreme, Thinning -> Harvest, and a removal selects on size rather than height. The pulse now rides an abstraction plant already had. Environments declare n_resources(); a pulse names one through target_index, exactly as a species event names a species, so Environment gains add_resource_pulse() and TF24 implements it over its soil layers. Model-specific names stay, where they are accurate: rainfall_pulse() and TF24_Environment::add_water_pulse() are the same action under the name that reads correctly there. Refs #628, #627, #522. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adopts odelia 0.3.1's opt-in domain checks. A non-finite environment state and an infeasible leaf probe both used to kill a run outright; both are now handed to the stepper to shrink and retry. If the minimum step still cannot escape, odelia stops and reports the original message, so nothing is lost. phylloptim's infeasible_error is a sibling of odelia's DomainError, not a subclass, so the stepper cannot see it -- solve_leaf() translates it. Only that one type, so a bug stays a bug rather than becoming step-shrinking. A runaway cohort density stays fatal: that divergence is in the equations, not the stepper. And ode_state_valid() checks only the environment block, because a node's log_density is legitimately -Inf. Inert where nothing is wrong: FF16, K93 and TF24 stay identical(). Refs #628, #608, #599. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
28e9185 to
a5b221c
Compare
Rebased onto develop again (#607, #608, #619, #605, #635)Re-measured rather than re-asserted, since TF24 moved substantially on develop in the meantime: the reference run is now 7350 ODE steps, up from 1065, with
Events path still One merge that needed care, and would have compiled wrong#619 added git auto-merged Two more resolved by hand rather than by side-picking:
Also refreshed
|
…lant into feature/events-522
The events-path guardrail loops over FF16 and K93 but asked for `lma` in both. K93 has no such parameter; it was silently ignored until #637 started refusing an unknown trait name, which is exactly what #637 is for. Perturbs each strategy's own default instead, so it survives the defaults moving as well. Refs #628, #637. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CI fix: #637 caught a real bug in one of my testsThe events-path guardrail loops over FF16 and K93 but asked for Fixed by perturbing each strategy's own default ( It passed locally only because my build predated #637. Also merged develop in, rather than rebasing again@itowers1 had pulled before my last force-push and pushed a merge resolving the collision. That merge's tree is byte-identical to my rebased tip — it added no content, only history. Rather than force-push over it a second time I merged This branch now carries #637, #640, #635, #605, #619, #608 and #607. Evidence re-earned against this develop
Events path still Step counts differ from my previous comment for two unrelated reasons: TF24 moved again on develop, and the FF16 figure changed because the harness now uses the strategy's default One thing worth knowing about the local pindevelop now pins |
There were some issues in the implementation found by GPT 5.6 Sol which should be reviewed by Claude: Event information is lost when
|
Collected results carry `events` and `event_log`. Events are supplied separately from `p`, so a collected run recorded neither the schedule nor what it applied -- including how much of a pulse the soil took and how much it shed. Validation moves ahead of the run. An event past max_patch_lifetime used to surface as the solver complaining about integrating backwards; a type aimed at a target it cannot act on was accepted and reinterpreted; a non-finite intensity, negative sensitivity or inverted size band each produced a confident, empty result. All are refused at construction, naming the row. Same-time ties are stable now, so two pulses at one instant are credited in the order given rather than reversed -- the log's whole job. events() sorts by type as well as time, so the object matches the order that runs. Harvest reports the density it removed, not only the cohort count. `events` moves to the end of run_scm()'s signature, keeping positional calls working. Refs #628, #629. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Thanks @elijahmagistrado — these were all real, and two of them were bugs I would not have found without a user of the interface. Addressed in a6a70f0. The two significant onesEvent record lost when Events past The minor onesTarget validation. Each type now declares every target it accepts, so an environment-aimed harvest or a patch-aimed pulse is refused, with the message naming what the type will take. Simultaneous ordering. Real bug, exactly as you describe: same-type same-time events were applied in reverse input order, so for two pulses at one instant the accepted and shed water was attributed to the wrong records. The queue's tie-break is stable now. This changed the tie order for same-time introductions too, which two While fixing it I found a related trap: Parameter validation. Argument position. Log wording. Took the "record the removed quantity" option rather than narrowing the wording: harvest and climate extremes now report On the science pointsRainfall pulses alongside continuous rainfall. I have gone with the warning rather than auto-disabling, because silently changing a driver the user set seemed worse than telling them: Moisture cascade first, Richards later; first-layer targeting is a placeholder. Agreed, and not attempted here. Simultaneous ordering untested. Now tested, both across types and within one type. Full suite |
elijahmagistrado
left a comment
There was a problem hiding this comment.
I am happy with the most recent changes.
Adds a general (time, action) event queue. Node introductions move onto it,
and rainfall pulses, thinning and heat damage join them as event types.
An event carries when it happens, its type, its target (environment, patch or
one species) and its values. What each one actually did -- as against what was
asked -- is readable as
scm$event_log.Runs supplying no events are unchanged: FF16, K93 and TF24 are identical() on
ODE step times, fitness and final state.
Closes #628. Unblocks #627 (thinning) and #601 (shared queue). Design notes in
notes/plan-events.md.