Record the design a steered run was given, not just the points it chose - #771
Merged
Conversation
…actory A steered run's design inputs are about to be recorded on the Procedure stream so the adaptive design is recoverable for inference. That payload must be typed rather than an opaque map: record-export dispositions are generated from the annotation, so a Mapping field publishes nothing and the pin would close none of the gaps it exists to close. The brain selection is one of those fields, and it cannot be typed with the DecideSubstrate Literal it names: that Literal is adapter-tier, and cora.operation.aggregates reaches only cora.infrastructure and cora.shared. So the enum lives here, beside the objective and space value types that moved for the same tach reason. Mirrors invite drift, so the fitness test is the point of this commit as much as the enum is. It compares two genuinely independent sides, a shared StrEnum and an adapter Literal with no common generator, and it was proven by mutation in both directions rather than merely written. SteeringDesignSource ships with a single value so the later across-Run arm is purely additive and no recorded pin is ever retroactively ambiguous about where its design came from. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The record holds what a steered run DID and nothing about what it was allowed to do. A downstream reader gets the points the brain chose and the values that came back, but not the objective, the search space, the budget or the brain's own settings. Rubin's criterion is that a sampling rule is ignorable for likelihood-based inference only if it depends on observed data, and that is verifiable only if the rule was written down. An unrecorded rule is not benign, it is unverifiable. So this adds the event that pins those inputs. Nothing emits it yet, on purpose: the risky part is not the emission, it is whether the record can carry the payload at all, and that is worth proving on its own. The typing is the whole point. Dispositions are generated from the annotation, so an opaque map would have exported as nothing and the pin would have closed none of the gaps it exists for. Objective and space are therefore the shared value objects, and the budget and brain config are flattened to scalars, because the aggregate tier may reach cora.shared but not the ports or adapters where their runtime types live. Campaign already serialized these two value objects privately. Those helpers move to cora.shared beside the types, so one object cannot be carried in two shapes on two streams; Campaign's payload is unchanged. One gap is pinned rather than fixed, with a test that fails when someone closes it: a populated search space still redacts to an empty dict, because the generator describes a collection of value objects with a dict-shaped rule while the stored value is a list. That is not specific to steering and it changes what several other contexts disclose, so it needs its own diff. It must land before anything emits this event, since an empty space reads as a zero-dimensional search rather than a withheld one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Five places said a conducted Procedure carries exactly one ResolvedStepsRecorded, including both resume handlers at the point where they read it. It is not true. The pin decider guards on status alone, so any conduct that fails after pinning and before start_procedure leaves the Procedure Defined and the retry pins again; three such paths exist on the steered entry point alone. Usually the second pin is identical and nothing is harmed, but the recipe hash check that would guarantee that runs BEFORE pseudoaxis expansion, and that stage resolves constituents from the parent Run's live wiring. Rewire a Run-phase Procedure between a failed attempt and its retry and the two pins genuinely differ, at which point the head-scanning reader replays the attempt that was abandoned rather than the one that ran. The finder shipped with no tests at all, so this characterizes it first: what it returns for no match, one match, two matches, and that it really does stop at the first. Those pin observed behaviour, and the two-match case is expected to invert when the read side is corrected, which its docstring says so a green result is not mistaken for a specification. No fix here and no hoist. The fix is read-side, take the last pin before ProcedureStarted, and it forfeits the early exit, so it wants its own diff. The rule-of-three hoist of the finders belongs with the third one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The redaction table is generated from field types, and for a collection of value objects it kept only the element's rule. A rule shaped like one object then met a list, the redactor withheld it, and the field published as an empty object. That is not a withheld field. It is a positive claim that there was nothing there, and thirteen fields across six contexts have been making it. The table can now say "collection of", so the redactor never has to guess cardinality from the payload it is redacting, and a stored shape that disagrees with the table fails closed again instead of publishing. Nothing sensitive starts flowing. Every drop rule still drops, every id is still tokenised, and what appears is bounds, counts, enums and numbers the table already authorised. Sweeping a marker through every drop-classified leaf of all 237 event types leaves none of them in an export. Three fields whose serializer disagrees with its declared type are withheld outright rather than reconciled, since changing a stored payload shape is a migration question and not an export one. Asset owners are the reason this matters: that serializer writes explicit nulls for absent values, so per-field rules would have disclosed which of name, contact or identifier is on file, with the reading inverted, over a field documented as usually an email. The singular sibling is withheld with it. Two exports that abort today stop aborting. A partition rule with any number of parameters other than two used to raise, and with exactly two it published the channel name verbatim; both are correct now, with withheld slots as nulls so the surviving positions still line up. The point of putting this in the generator rather than the redactor is that the redaction profile hash covers the table. A consumer pinning that hash sees this change; had the redactor learned to guess instead, fifteen fields would have widened under an unchanged hash. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The objective, the search space, the budget and the brain configuration reached the runtime as command arguments and were persisted nowhere. No Procedure event pinned them, NewEvent carries command_name as a bare string with no payload, and there is no command log. So a steered run's record could describe every point that was chosen and nothing about the rule that chose them, and a sampling rule that is not recorded is not thereby harmless, it is unverifiable. The pin rides the existing pre-conduct pipeline and goes in the SAME append as ResolvedStepsRecorded, off one clock reading. A second append would open a window in which the steps are pinned and the design that chose them is not, manufacturing exactly the absence this event exists to remove. The duplicate guard is load-bearing here in a way it is not for the steps pin. Two failure paths fire after the pin and leave the Procedure Defined, so a retry pins again. For the steps that repetition is byte-identical and harmless, but here the operator has usually corrected the space in between and the second design is the one that ran. An identical re-pin is therefore suppressed and a corrected one is not, and the reader scans from the tail, where a head-scan would compare against the abandoned attempt. The two existing finders scan from the head and return that abandoned attempt; that is a recorded read-side defect, not a convention to follow. Comparison is over the serialized payload rather than the rebuilt event, so a row written before a field existed makes the two designs differ instead of raising, and differing means pin again, which is the safe direction. A stored payload carrying a key this code cannot read counts as different for the same reason. Unsteered conduct is untouched: steering_design defaults to None and the three unsteered callers of the shared pipeline pass nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four sites across two contexts wrote the same four-line loop to pull one event out of a loaded stream, and the fourth was written as a genexp in the Data context rather than a loop, so a grep for the pattern found three. The recorded rule-of-three note asked for the hoist once a third landed. What the loop was hiding is worth more than the duplication it caused. Each site had already made a decision about DIRECTION, and none of them said so where a reader would look. The genesis record is the first of its type and cannot be superseded. The steps pin is re-emitted by a retry, so the first one is the abandoned attempt, and reading from the head is a known read-side defect. The steering design pin is re-emitted the same way and reads from the tail for exactly that reason. find_first_event and find_last_event now carry the loop, and the named domain finders keep their docstrings and say which direction they picked and why, since that is the part a caller needs. The two finders disagreeing on a stream where a type repeats is itself a test: a single scan wired to both would pass every assertion made about either one alone. No behaviour changes. The existing characterization tests for both head scanners still pass unchanged, and they go red together with the new tests when find_first_event is made to scan backwards, which is what shows the wrappers really delegate rather than keeping a private copy. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…uous one A steered Procedure can be held and resumed, and until now the resume carried its objective, space, budget and brain config in as command arguments and left no trace of any of them. A Procedure held and resumed under a different substrate ended its life with a record describing only its first half. So the resume pins its own design. It appends alone rather than beside a steps pin, because a resume replays the already-pinned step list instead of resolving a new one, and that is the only event there is to write. The status guard is now a required argument rather than a default: the forward path pins while Defined and the resume while Held, and accepting both at once would let a forward conduct against a Held Procedure emit a design pin with no steps beside it and only then fail in the Conductor. A resume whose objective, capture name or space left the pinned design behind is refused with 422 before any FSM event fires. Only the space has a first-principles argument: it is the support the recorded observations were drawn from, and resuming a brain over that history while it proposes elsewhere asks it to extrapolate. The other two are a conservative default and the docstrings now say so rather than borrowing the space's argument, because neither survives it: the objective is applied when the brain fits, not when the data was recorded, and the capture name's real condition is agreement with the pinned steps. The asymmetry that leaves is stated outright. The substrate is part of the sampling rule and may change freely, which is defensible only because this event records it. The check reads the pin the last segment actually STARTED under, not the newest pin on the stream, and that distinction is a bug fix rather than a refinement. A pin is written before the Conductor runs, and the wire guard, the brain factory and the resume's own authorization can all still refuse. On a stream with no earlier pin, a first attempt that mistypes an axis leaves a pin nothing started under; measuring against it accepts only the design the wire guard just rejected, and the Procedure can never be resumed again. Both remedies the error names are wrong in that state: one is the rejected design, the other discards every accumulated observation. A pin no segment started under governed nothing. Absence stays silent. Every Procedure conducted before this event existed carries steps and no design, and refusing those would make a record-keeping improvement retroactively break resumes that were fine. Two segments under one design leave one pin, because the duplicate guard suppresses an unchanged resume. Counting pins does not count segments; a reader attributes a pass to the most recent pin at or before it, and the FSM events delimit the segments. The event docstring said otherwise and now does not. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The rule was that objective, capture name and space must all survive a hold unchanged, on the grounds that the observations already recorded were drawn under them. That reason holds for none of the three as stated, and checking what three widely used optimisation libraries actually do says so. Optuna records the distribution per trial and refuses a change of kind, of log scale, or of categorical choices. It deliberately does not compare low, high or step, and its FAQ says outright that a study may alter a range between trials. Ax lets the objective be replaced while trials exist, and lets the search space be replaced provided parameter names and types survive, demanding backfill values before a new parameter may be added. Ax also stores the search space and the objective on every generator run, and the flag that turns those copies off is the same flag that makes both immutable, because once the change is not recorded it can no longer be permitted. That is the entailment, and this codebase now sits on the recorded side of it. So the refusal narrows to what actually strands data. Each recorded pass holds one coordinate per axis, and only three changes leave a coordinate with nowhere to live: an axis that disappears, an axis that appears, and a categorical axis that drops a value already drawn. Those still refuse. Bounds, objective, capture name, budget and brain now travel on the resumed segment's own pin instead, where a reader can see them. The argument for freezing bounds was mine and it was backwards. Narrowing them leaves the model fitted on a wider set than it proposes within, which is interpolation; widening leaves the new region empty, where the model reports high variance and goes to look, which is exploration. Neither strands anything. The failure is dimensional, not geometric, which is what Ax's backfill demand and Optuna's categorical rule were both pointing at. The refusal now names the axis rather than the field, because a renamed axis reports as both a disappearance and an appearance, and that is what a typo looks like from the inside. One thing is deliberately left undone. The capture name's real requirement is that the block deposits that slot, and the Conductor already enforces it, but only once a pass has run. Promoting it to a wire-time refusal would help both steered paths, and it would also leave the runtime branch unreachable from either of them, which matters because that branch carries the only cover for a recorded ledger-drop bug. Renaming the slot on a resume therefore costs one pass, exactly as it already does going forwards. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e docs Every test of this event so far ran against the in-memory store, which answers whether the right events get appended but not whether the payload comes back the shape it went in. That gap matters more here than for most events. The objective and the search space are nested value objects, the space itself a list of axes, and the entire reason the event is typed rather than blobbed was to keep them out of the opaque bucket on export. A structure that serializes cleanly in process and returns from asyncpg flattened would have defeated that silently. Nothing covered it. The rotation-characterization scenario drives the Conductor directly and so never reaches the handler that writes the pin, which means a pin broken only against Postgres would have shipped with the suite green. Four tests now drive the real steered handlers against a real database: both value objects round-tripped whole rather than spot-checked, the two pins adjacent in one append before the FSM opens, a resume recording the narrowed bounds it actually ran under, and a refused resume leaving the stream byte-identical, since a guard that appends before it decides is not a refusal. The held fixture runs a genuine forward conduct and then appends the hold directly. A conduct that has returned is already terminal, so there is no moment in a test at which hold_procedure would be accepted; the helper says so rather than leaving the shortcut to be discovered. The event also reaches the module documentation, whose table no fitness test guards and which therefore rots in silence. The surrounding prose claimed a steered run was reconstructable from its recorded decision trail, which is true of what the brain did and says nothing about what it was asked, so that paragraph now carries the distinction and the rule for what a resume may change. While there, the sibling row for the resolved-steps pin regains the closing-steps field it has been missing since that field shipped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Coverage reportClick to see where and how coverage changed
This report was generated by python-coverage-comment-action |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
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.
Why
CORA recorded every steering output and no steering input. A steered run's stream held the advised points, the measurements, the brain diagnostics and the order they arrived in, but nothing said what objective the brain was optimizing, what space it was allowed to search, what budget it was given, or which substrate resolved the advice.
NewEvent.command_nameis a bare string with no payload and there is no command log, so the design inputs were not recoverable from anywhere.That makes a run describable but not interpretable. Rubin's ignorability condition (Biometrika 63(3):581, 1976) is the sharp form: a sampling rule is ignorable only if it can be checked, and it can only be checked if it was recorded. Replay sufficiency and inferential sufficiency are different criteria, and the second was missing.
What
A provenance-only
SteeringDesignRecordedevent on the Procedure stream, typed rather than blobbed so record export keeps the objective and the search space out of the opaque bucket. It has no evolver fold arm beyondrequire_state: it changes no aggregate state and gates nothing.Written on the initial conduct beside the resolved-steps pin, in one append under one clock read, before the FSM opens. Written again on a resume when the design differs, suppressed when it is byte-identical.
A resume is refused only when the new space cannot represent a coordinate already measured: an axis dropped, an axis renamed, or a categorical choice removed. Bounds may narrow or widen, the objective may change, the budget and the brain may change, all recorded rather than refused. Recording is what buys that freedom.
Notable
ProcedureStarted/ProcedureResumed. Reading the latest in both places let a failed first resume brick a legacy stream permanently: the pin lands before the wire guard refuses, and the check then accepts only the design that was just rejected. Found in independent review, reproduced as a failing test, then fixed._conduct_preparationgained one stream-scan home.find_first_event/find_last_eventhoisted tocora.infrastructure.event_payload; four call sites converted, each keeping the docstring that justifies its direction. The fourth site was written as a genexp, which is why the earlier rule-of-three note missed it.tuple[ValueObject, ...]field published as{}: not a withheld field but a positive claim of emptiness, across thirteen fields in six contexts. Fixed in the generator rather than the redactor, which is what moves the redaction profile hash; a redactor-only fix would have widened fifteen fields under an unchanged hash. It also repaired two exports that aborted outright.Verification
Recorded, not fixed
A principal denied
ResumeProcedurecan still append a pin before the Conductor's authz refuses; promoting the objective-capture-slot check to wire time;spend_agent_idmay change across a hold unremarked; the read-side duplicate defect on the steps pin; three smaller export items.🤖 Generated with Claude Code