Read Stratum's behavior timings contract, unwired - #137
Merged
Merged
Conversation
Stratum's per-behavior timings come out of its accumulator as flat strings built by concatenation at the call site: entity.behavior.players.health, entity.behavior.threadsafe.creatures.done-behavior-entitypassivephysics, entity.ai.creatures.task.idle, entity.type.wolf-eurasian-adult-male. The parser turns each one into the family it feeds and that family's label values, and nothing else. The four AI phase keys, which nest inside each other and inside the entity totals, the sections Stratum times on its own and anything malformed are ignored rather than guessed at, which is also what keeps a key family a future Stratum adds from appearing under a wrong name. A thread-safe behavior carries the engine's profiler mark, so its done-behavior- prefix is stripped and both threads speak the same names; a main-thread name is taken as written. An entity is named by the first part of its code, cut at the first dash like the engine's own FirstCodePart (a test compares the two), so every variant of a wolf is one key. The parse is memoised by key text: Stratum builds a fresh string for nearly every key at every call, and a repeated key now costs one lookup and allocates no label strings. Nothing reads this yet.
The accumulator's totals are cumulative, so a burst is the difference of two snapshots, one taken when it began and one when it ended. The fold turns that difference into seconds per series (behavior, AI task, entity type) and, for entity types, the number of ticks run, which is what makes seconds per entity tick one division. A key that is not in the first snapshot appeared during the burst and everything it holds is the burst's, which is nearly every key, since Stratum empties its accumulator when the last reader lets go. A total that went down was reset in between and counts as new; a negative one is nonsense and adds nothing, so no series is ever told to go down. A key the burst added nothing to is not a series and takes no place under the cap. Each family keeps at most 100 series. The first label sets to appear get one and keep it for the life of the server, and everything after is added to a single series per family whose labels all read other. Nothing is evicted or re-ranked, because a series that moved in and out of other would make other go down, which Prometheus reads as a counter reset. When a burst brings more new label sets than there are places, as the first one can, the heaviest are admitted first and ties go in label order, so the cheap tail is what gets lumped together and the same burst gives the same series on every run. The burst says which families spilled for the first time, so the caller can log that once. A behavior's modid comes from the ModOwners table attribution already uses, which learns what a behavior marks with from the live entities. It is looked up at every burst rather than remembered: a name the table only learns later moves to its mod once, instead of staying unattributed for the rest of the run, and the series it leaves simply stops growing. The cap counts the label sets that carry a name, so that move does not take a second place. Nothing reads this yet.
Stratum is adding a small public contract to its entity behavior timings: a ContractVersion literal, a RequestRecording lease and a Snapshot of the cumulative totals that takes nothing from the admin's own report. Pulse compiles against the vanilla API, so it cannot name that type. The binder looks for the contract on the type the caller hands it, checks its shape exactly, and binds the two members to delegates once, so a burst is three delegate calls and a dispose. Nothing is guessed. A type with no ContractVersion that is an integer literal is a Stratum that predates the contract, and the reason names the release that has it (a placeholder constant until Stratum publishes one). Another version number, a missing member and a member of another shape each earn a reason of their own, and a type that is absent (vanilla, Lithos, another fork) is not a failure and has none. The version is read from metadata, so no Stratum code runs before Pulse knows what it is looking at. The tests bind fakes: one with the contract's exact shape, declared under Stratum's real full name in the test assembly so that the by-name lookup has something to find, and one for each way a Stratum can fail to have it. A last test runs a whole burst through the binder, the fake accumulator and the fold. Nothing is wired to the mod yet.
Fifty-seven mutations over the binder, the parser and the fold, applied one at a time and each required to fail the unit tests. The binder's are every check that stands between Pulse and a Stratum it does not understand: a type that is absent, a ContractVersion that is not an integer literal or is not the one Pulse reads, a member that is missing, internal, an instance method or not the exact shape, and the plumbing of the two delegates. The parser's are the key shapes it reads and the ones it must leave alone, the prefix strip and the first-dash cut. The fold's are the arithmetic (the difference, the reset, the clamp, the conversion to seconds), the cap (its comparison, its default, admission order and its tie-breaks, never evicting), the overflow series, the once-only report of a family spilling, and the owner lookup. The block has a list of files of its own to refuse a dirty tree on, and sits between two unrelated blocks of the script.
An independent review of the Stratum timings pieces found no blocker and six things to settle. This settles them. What spills over the cap keeps its category and its thread, and only the name reads other, with the modid that goes with a behavior's name. The thread separates CPU time summed across the physics threads from main-thread time, so a panel that filters on threadsafe="true" no longer loses what the cap lumped together. The overflow is one series for each category and thread a family spilled in: at most four for behaviors, three for tasks and one for entity types. A reset is read for the whole key. When either total went down, ticks or calls, both are taken as the second snapshot holds them, instead of pairing an end value with a delta, which gave seconds and a count that were not each other's. A ContractVersion that is there but is not an integer literal (a static readonly field, a long, a property) is a wrong shape and not an old Stratum, so it gets the reason about the members. Only a Stratum with no public ContractVersion is told which release it needs. The tests compared seconds exactly, on readings built as a truncated product with the stopwatch frequency, which holds only when the frequency is a multiple of 4: at 3579545 Hz seven of them failed on correct code. They now round to the nearest tick and compare within a few ticks, and the burst through the binder uses whole seconds. Two tie-break tests that would have passed on the wrong series, since the overflow series now shares its category or its thread, check the name too. The comments said a burst usually opens on an empty accumulator. The baseline is meant to be taken on the burst's warm-up tick, when it holds nearly every key. They say that, and the burst through the binder takes its baseline there. The three mutations these changes made inert (the two overflow labels and the internal ContractVersion) go. The name of the overflow series, the joint reset and the honest reason get one each, and the ones on the reset are aimed at the single place it is decided now.
The Build and tests job has a 30 minute limit and has timed out twice, and with the three open branches the mutation step grows to about 197 mutations. Sixteen of the Stratum timings ones go as low value, which leaves 41 of them. From the fold: the three tie-breaks of the admission order (the tie-break itself stays, so the same burst still admits the same series on every run, and its tests stay), the default cap, and the calls of a type that sums several codes. From the binder: the text of the warning, a long read as an int, an older version, a snapshot that returns a value, and the two binding flags that would admit internal members or an instance method. From the parser: the five cases of an empty category, an empty name, a type cut at the last dash, a type that is the whole code path and a path that starts with a dash. The tests that guard them are untouched.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Step 5 of the plan for #6: the pieces that read Stratum's behavior timings, written and tested against fakes and not wired into the mod. Nothing is registered and nothing served changes. The reading contract it binds, StratumServer/Stratum#365, was approved and merged into Stratum's
indevon 2026-10-05. The wiring (step 6) waits for a Stratum release that carries the contract.What it adds (all internal)
StratumTimingsSourcebinds the contract by reflection. It looks forVintagestory.API.Common.Entities.StratumEntityBehaviorTimingsonly in theVintagestoryAPIassembly the server loaded, readsContractVersionas anintliteral, and bindsRequestRecording()andSnapshot(List<(string, long, long)>)once as typed delegates. Pulse's IL never names a Stratum type, so vanilla and Lithos are unaffected.Snapshot(object).MinimumStratumVersionis a marked placeholder, filled in once a Stratum release carries the contract.StratumKeyParsermaps an accumulator key to a family and its labels. It reads the four key forms the contract now documents, ignores the AI phase keys, and memoises its results.entity.behavior.players.healthplayers, behaviorhealth, threadsafefalse, modid fromModOwnersentity.behavior.threadsafe.creatures.done-behavior-entitypassivephysicscreatures, behaviorentitypassivephysics, threadsafetrueentity.ai.creatures.task.idlecreatures, taskidleentity.type.wolf-eurasian-adult-malewolf, seconds and tick countStratumFoldturns two snapshots taken around a burst into per-series deltas in seconds, plus entity tick counts.other.modidcomes from theModOwnersthat attribution already uses.Checked
Stopwatch.Frequency;tools/mutation-check.shkills 181 of 181 on this branch rebased onto dev, 41 of them for these pieces.The points the wiring has to get right are listed for step 6, among them a
ModOwnersshared outside attribution, a fold built once, and the exact tick window.